diff --git a/pkg/microservice/aslan/config/consts.go b/pkg/microservice/aslan/config/consts.go index d23488f039..177fa1e077 100644 --- a/pkg/microservice/aslan/config/consts.go +++ b/pkg/microservice/aslan/config/consts.go @@ -259,6 +259,7 @@ const ( JobBlueKing JobType = "blueking" JobApproval JobType = "approval" JobNotification JobType = "notification" + JobAIReleaseSpecialist JobType = "ai-release-specialist" JobSAEDeploy JobType = "sae-deploy" JobApisix JobType = "apisix" ) diff --git a/pkg/microservice/aslan/core/common/repository/models/wokflow_task_v4.go b/pkg/microservice/aslan/core/common/repository/models/wokflow_task_v4.go index 010a016441..9a69ca51b5 100644 --- a/pkg/microservice/aslan/core/common/repository/models/wokflow_task_v4.go +++ b/pkg/microservice/aslan/core/common/repository/models/wokflow_task_v4.go @@ -689,6 +689,65 @@ type JobTaskApprovalSpec struct { ApprovalMessage string `bson:"approval_message" yaml:"approval_message,omitempty" json:"approval_message,omitempty"` } +type JobTaskAIReleaseSpecialistSpec struct { + Timeout int64 `bson:"timeout" json:"timeout" yaml:"timeout"` + PromptTemplate string `bson:"prompt_template" json:"prompt_template" yaml:"prompt_template"` + RequireManualConfirm bool `bson:"require_manual_confirm" json:"require_manual_confirm" yaml:"require_manual_confirm"` + ConfirmUsers []*User `bson:"confirm_users" json:"confirm_users" yaml:"confirm_users"` + NativeApproval *NativeApproval `bson:"native_approval,omitempty" json:"native_approval,omitempty" yaml:"native_approval,omitempty"` + Input *AIReleaseSpecialistInput `bson:"input,omitempty" json:"input,omitempty" yaml:"input,omitempty"` + Result *AIReleaseSpecialistResult `bson:"result,omitempty" json:"result,omitempty" yaml:"result,omitempty"` + ChangeSummaryText string `bson:"change_summary_text,omitempty" json:"change_summary_text,omitempty" yaml:"change_summary_text,omitempty"` +} + +type AIReleaseSpecialistInput struct { + ReleaseTargets *AIReleaseTargetsSummary `json:"release_targets,omitempty"` + ChangeSummary *AIChangeSummary `json:"change_summary,omitempty"` + ScanSummary *AIScanSummary `json:"scan_summary,omitempty"` + TestSummary *AITestSummary `json:"test_summary,omitempty"` +} + +type AIReleaseTargetsSummary struct { + EnvName string `json:"env_name,omitempty"` + EnvAlias string `json:"env_alias,omitempty"` + Production bool `json:"production,omitempty"` + ServiceNames []string `json:"service_names,omitempty"` + ImageVersions []string `json:"image_versions,omitempty"` + TargetCount int `json:"target_count,omitempty"` +} + +type AIChangeSummary struct { + Remark string `json:"remark,omitempty"` + Branches []string `json:"branches,omitempty"` + Tags []string `json:"tags,omitempty"` + CommitMessages []string `json:"commit_messages,omitempty"` + Services []string `json:"services,omitempty"` +} + +type AIScanSummary struct { + JobStatuses []string `json:"job_statuses,omitempty"` + Summaries []string `json:"summaries,omitempty"` +} + +type AITestSummary struct { + JobStatuses []string `json:"job_statuses,omitempty"` + Summaries []string `json:"summaries,omitempty"` +} + +type AIReleaseSpecialistResult struct { + Conclusion string `json:"conclusion"` + Summary string `json:"summary"` + Checks []*AIReleaseSpecialistCheckItem `json:"checks"` + RawText string `json:"raw_text"` +} + +type AIReleaseSpecialistCheckItem struct { + Name string `json:"name"` + Result string `json:"result"` + Evidence string `json:"evidence"` + Suggestion string `json:"suggestion"` +} + type JobTaskWorkflowTriggerSpec struct { TriggerType config.WorkflowTriggerType `bson:"trigger_type" json:"trigger_type" yaml:"trigger_type"` IsEnableCheck bool `bson:"is_enable_check" json:"is_enable_check" yaml:"is_enable_check"` diff --git a/pkg/microservice/aslan/core/common/repository/models/workflow_v4.go b/pkg/microservice/aslan/core/common/repository/models/workflow_v4.go index 1e55fb9d9c..baaa39ea01 100644 --- a/pkg/microservice/aslan/core/common/repository/models/workflow_v4.go +++ b/pkg/microservice/aslan/core/common/repository/models/workflow_v4.go @@ -1188,6 +1188,13 @@ type NotificationJobSpec struct { IsAtAll bool `bson:"is_at_all" yaml:"is_at_all" json:"is_at_all"` } +type AIReleaseSpecialistJobSpec struct { + Timeout int64 `bson:"timeout" json:"timeout" yaml:"timeout"` + PromptTemplate string `bson:"prompt_template" json:"prompt_template" yaml:"prompt_template"` + RequireManualConfirm bool `bson:"require_manual_confirm" json:"require_manual_confirm" yaml:"require_manual_confirm"` + ConfirmUsers []*User `bson:"confirm_users" json:"confirm_users" yaml:"confirm_users"` +} + // GenerateNewNotifyConfigWithOldData use the data before 3.3.0 in notifyCtl and generate the new config data based on the deprecated data. func (n *NotificationJobSpec) GenerateNewNotifyConfigWithOldData() error { switch n.WebHookType { diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job.go index 50acd3e5a9..bf2c7911bd 100644 --- a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job.go +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job.go @@ -113,6 +113,8 @@ func initJobCtl(job *commonmodels.JobTask, workflowCtx *commonmodels.WorkflowTas jobCtl = NewBlueKingJobCtl(job, workflowCtx, ack, logger) case string(config.JobApproval): jobCtl = NewApprovalJobCtl(job, workflowCtx, ack, logger) + case string(config.JobAIReleaseSpecialist): + jobCtl = NewAIReleaseSpecialistJobCtl(job, workflowCtx, ack, logger) case string(config.JobNotification): jobCtl = NewNotificationJobCtl(job, workflowCtx, ack, logger) case string(config.JobSAEDeploy): diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist.go new file mode 100644 index 0000000000..46a53b0f92 --- /dev/null +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist.go @@ -0,0 +1,724 @@ +/* +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 jobcontroller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" + commonutil "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/util" + "github.com/koderover/zadig/v2/pkg/setting" + "github.com/koderover/zadig/v2/pkg/tool/llm" + runtimejob "github.com/koderover/zadig/v2/pkg/types/job" + steptypes "github.com/koderover/zadig/v2/pkg/types/step" +) + +const ( + aiReleaseSpecialistDefaultTimeoutMinutes = 60 + aiReleaseSpecialistMaxPromptTokens = 12000 +) + +const defaultAIReleaseSpecialistSystemPrompt = "你是发布前检查助手。\n\n" + + "请基于下面的发布上下文,输出一个 JSON 代码块,不要输出额外解释文字。\n\n" + + "JSON schema:\n" + + "{\n" + + " \"conclusion\": \"pass|warning|fail\",\n" + + " \"summary\": \"一句到三句中文总结\",\n" + + " \"checks\": [\n" + + " {\n" + + " \"name\": \"检查项名称\",\n" + + " \"result\": \"pass|warning|fail\",\n" + + " \"evidence\": \"判断依据\",\n" + + " \"suggestion\": \"建议动作\"\n" + + " }\n" + + " ]\n" + + "}" + +type AIReleaseSpecialistPromptDebugResult struct { + SystemPrompt string + Prompt string + PromptTokens int + PromptTooLarge bool +} + +type AIReleaseSpecialistJobCtl struct { + job *commonmodels.JobTask + workflowCtx *commonmodels.WorkflowTaskCtx + logger *zap.SugaredLogger + jobTaskSpec *commonmodels.JobTaskAIReleaseSpecialistSpec + ack func() +} + +func NewAIReleaseSpecialistJobCtl(job *commonmodels.JobTask, workflowCtx *commonmodels.WorkflowTaskCtx, ack func(), logger *zap.SugaredLogger) *AIReleaseSpecialistJobCtl { + jobTaskSpec := &commonmodels.JobTaskAIReleaseSpecialistSpec{} + if err := commonmodels.IToi(job.Spec, jobTaskSpec); err != nil { + logger.Error(err) + } + job.Spec = jobTaskSpec + return &AIReleaseSpecialistJobCtl{ + job: job, + workflowCtx: workflowCtx, + logger: logger, + jobTaskSpec: jobTaskSpec, + ack: ack, + } +} + +func (c *AIReleaseSpecialistJobCtl) Clean(ctx context.Context) {} + +func (c *AIReleaseSpecialistJobCtl) Run(ctx context.Context) { + c.job.Status = config.StatusRunning + c.ack() + jobStartTime := time.Now() + jobCtx := ctx + cancel := func() {} + if timeout := c.getJobTimeout(); timeout > 0 { + jobCtx, cancel = context.WithTimeout(ctx, time.Duration(timeout)*time.Minute) + } + defer cancel() + + task, err := mongodb.NewworkflowTaskv4Coll().Find(c.workflowCtx.WorkflowName, c.workflowCtx.TaskID) + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("find workflow task failed: %v", err) + c.ack() + return + } + + input, err := BuildAIReleaseSpecialistInputFromTask(task, c.job.Name) + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("build ai release specialist input failed: %v", err) + c.ack() + return + } + c.jobTaskSpec.Input = input + + prompt, err := BuildAIReleaseSpecialistPrompt(c.jobTaskSpec.PromptTemplate, input) + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("build ai release specialist prompt failed: %v", err) + c.ack() + return + } + + client, err := getDefaultAIReleaseSpecialistLLMClient(jobCtx) + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("get default llm client failed: %v", err) + c.ack() + return + } + + options := []llm.ParamOption{ + llm.WithTemperature(0.1), + llm.WithMaxTokens(3000), + } + if client.GetModel() != "" { + options = append(options, llm.WithModel(client.GetModel())) + } + + answer, err := client.GetCompletion(jobCtx, prompt, options...) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(jobCtx.Err(), context.DeadlineExceeded) { + c.job.Status = config.StatusTimeout + c.job.Error = "ai release specialist timeout" + } else { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("llm completion failed: %v", err) + } + c.ack() + return + } + + result, err := ParseAIReleaseSpecialistResult(answer) + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("parse llm result failed: %v", err) + c.ack() + return + } + c.jobTaskSpec.Result = result + c.jobTaskSpec.ChangeSummaryText = buildChangeSummaryText(input.ChangeSummary) + writeAIReleaseSpecialistOutputs(c.workflowCtx, c.job.Key, c.jobTaskSpec.Result) + c.ack() + + if result.Conclusion == "fail" { + c.job.Status = config.StatusFailed + if result.Summary != "" { + c.job.Error = result.Summary + } else { + c.job.Error = "ai release specialist check failed" + } + c.ack() + return + } + + if c.jobTaskSpec.RequireManualConfirm { + approvalUsers, err := c.getRuntimeConfirmUsers() + if err != nil { + c.job.Status = config.StatusFailed + c.job.Error = fmt.Sprintf("expand confirm users failed: %v", err) + c.ack() + return + } + remainingTimeout := c.getRemainingTimeout(jobStartTime) + if remainingTimeout <= 0 { + c.job.Status = config.StatusTimeout + c.job.Error = "ai release specialist timeout" + c.ack() + return + } + approvalSpec := &commonmodels.JobTaskApprovalSpec{ + Timeout: remainingTimeout, + Type: config.NativeApproval, + NativeApproval: &commonmodels.NativeApproval{ + ApproveUsers: approvalUsers, + NeededApprovers: 1, + Timeout: int(remainingTimeout), + }, + } + c.jobTaskSpec.NativeApproval = approvalSpec.NativeApproval + c.job.Status = config.StatusWaitingApprove + c.ack() + + status, err := waitForNativeApprove(jobCtx, approvalSpec, c.workflowCtx.WorkflowName, c.job.Name, c.workflowCtx.TaskID, c.ack) + c.job.Status = status + if err != nil { + c.job.Error = err.Error() + } + return + } + + c.job.Status = config.StatusPassed +} + +func (c *AIReleaseSpecialistJobCtl) SaveInfo(ctx context.Context) error { + return mongodb.NewJobInfoColl().Create(ctx, &commonmodels.JobInfo{ + Type: c.job.JobType, + WorkflowName: c.workflowCtx.WorkflowName, + WorkflowDisplayName: c.workflowCtx.WorkflowDisplayName, + TaskID: c.workflowCtx.TaskID, + ProductName: c.workflowCtx.ProjectName, + StartTime: c.job.StartTime, + EndTime: c.job.EndTime, + Duration: c.job.EndTime - c.job.StartTime, + Status: string(c.job.Status), + }) +} + +func (c *AIReleaseSpecialistJobCtl) getJobTimeout() int64 { + if c.jobTaskSpec.Timeout > 0 { + return c.jobTaskSpec.Timeout + } + return aiReleaseSpecialistDefaultTimeoutMinutes +} + +func (c *AIReleaseSpecialistJobCtl) getRemainingTimeout(jobStartTime time.Time) int64 { + remainingDuration := time.Duration(c.getJobTimeout())*time.Minute - time.Since(jobStartTime) + if remainingDuration <= 0 { + return 0 + } + return int64(math.Ceil(remainingDuration.Minutes())) +} + +func (c *AIReleaseSpecialistJobCtl) getRuntimeConfirmUsers() ([]*commonmodels.User, error) { + flatUsers, _ := commonutil.GeneFlatUsersWithCaller(c.jobTaskSpec.ConfirmUsers, c.workflowCtx.WorkflowTaskCreatorUserID) + if len(flatUsers) == 0 { + return nil, fmt.Errorf("confirm users are empty") + } + for _, user := range flatUsers { + if user == nil { + return nil, fmt.Errorf("confirm user cannot be nil") + } + user.Type = setting.UserTypeUser + if user.UserID == "" { + return nil, fmt.Errorf("confirm user id cannot be empty") + } + } + return flatUsers, nil +} + +func BuildAIReleaseSpecialistInputFromTask(task *commonmodels.WorkflowTask, currentJobName string) (*commonmodels.AIReleaseSpecialistInput, error) { + input := &commonmodels.AIReleaseSpecialistInput{ + ChangeSummary: &commonmodels.AIChangeSummary{ + Remark: strings.TrimSpace(task.Remark), + }, + } + + var ( + releaseTargets []*commonmodels.AIReleaseTargetsSummary + scanStatuses []string + scanSummaries []string + testStatuses []string + testSummaries []string + ) + + for _, stage := range task.Stages { + for _, job := range stage.Jobs { + if job.Name == currentJobName { + return finalizeAIReleaseSpecialistInput(input, releaseTargets, scanStatuses, scanSummaries, testStatuses, testSummaries), nil + } + + switch job.JobType { + case string(config.JobZadigDeploy): + spec := &commonmodels.JobTaskDeploySpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + continue + } + releaseTargets = append(releaseTargets, buildReleaseTargetFromDeploy(spec)) + case string(config.JobZadigHelmDeploy): + spec := &commonmodels.JobTaskHelmDeploySpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + continue + } + releaseTargets = append(releaseTargets, buildReleaseTargetFromHelmDeploy(spec)) + case string(config.JobZadigHelmChartDeploy): + spec := &commonmodels.JobTaskHelmChartDeploySpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + continue + } + releaseTargets = append(releaseTargets, buildReleaseTargetFromHelmChartDeploy(spec)) + case string(config.JobZadigBuild): + spec := &commonmodels.JobTaskFreestyleSpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + continue + } + collectChangeSummaryFromFreestyleSpec(input.ChangeSummary, spec) + case string(config.JobZadigScanning): + scanStatuses = append(scanStatuses, fmt.Sprintf("%s:%s", job.OriginName, job.Status)) + scanSummaries = append(scanSummaries, buildResultSummaryLine(job)) + case string(config.JobZadigTesting): + testStatuses = append(testStatuses, fmt.Sprintf("%s:%s", job.OriginName, job.Status)) + testSummaries = append(testSummaries, buildResultSummaryLine(job)) + } + } + } + + return finalizeAIReleaseSpecialistInput(input, releaseTargets, scanStatuses, scanSummaries, testStatuses, testSummaries), nil +} + +func finalizeAIReleaseSpecialistInput(input *commonmodels.AIReleaseSpecialistInput, releaseTargets []*commonmodels.AIReleaseTargetsSummary, scanStatuses, scanSummaries, testStatuses, testSummaries []string) *commonmodels.AIReleaseSpecialistInput { + if len(releaseTargets) > 0 { + input.ReleaseTargets = mergeReleaseTargets(releaseTargets) + } + if len(scanStatuses) > 0 || len(scanSummaries) > 0 { + input.ScanSummary = &commonmodels.AIScanSummary{ + JobStatuses: uniqueSortedStrings(scanStatuses), + Summaries: uniquePreserveOrder(scanSummaries), + } + } + if len(testStatuses) > 0 || len(testSummaries) > 0 { + input.TestSummary = &commonmodels.AITestSummary{ + JobStatuses: uniqueSortedStrings(testStatuses), + Summaries: uniquePreserveOrder(testSummaries), + } + } + input.ChangeSummary.Branches = uniqueSortedStrings(input.ChangeSummary.Branches) + input.ChangeSummary.Tags = uniqueSortedStrings(input.ChangeSummary.Tags) + input.ChangeSummary.Services = uniqueSortedStrings(input.ChangeSummary.Services) + input.ChangeSummary.CommitMessages = uniquePreserveOrder(input.ChangeSummary.CommitMessages) + return input +} + +func buildReleaseTargetFromDeploy(spec *commonmodels.JobTaskDeploySpec) *commonmodels.AIReleaseTargetsSummary { + target := &commonmodels.AIReleaseTargetsSummary{ + EnvName: spec.Env, + Production: spec.Production, + } + if spec.ServiceName != "" { + target.ServiceNames = append(target.ServiceNames, spec.ServiceName) + } + if spec.ServiceModule != "" && spec.Image != "" { + target.ImageVersions = append(target.ImageVersions, spec.Image) + target.TargetCount++ + } + for _, serviceAndImage := range spec.ServiceAndImages { + if spec.ServiceName != "" { + target.ServiceNames = append(target.ServiceNames, spec.ServiceName) + } + if serviceAndImage.Image != "" { + target.ImageVersions = append(target.ImageVersions, serviceAndImage.Image) + } + target.TargetCount++ + } + target.ServiceNames = uniqueSortedStrings(target.ServiceNames) + target.ImageVersions = uniquePreserveOrder(target.ImageVersions) + if target.TargetCount == 0 && len(target.ServiceNames) > 0 { + target.TargetCount = len(target.ServiceNames) + } + return target +} + +func buildReleaseTargetFromHelmDeploy(spec *commonmodels.JobTaskHelmDeploySpec) *commonmodels.AIReleaseTargetsSummary { + target := &commonmodels.AIReleaseTargetsSummary{ + EnvName: spec.Env, + Production: spec.IsProduction, + } + if spec.ServiceName != "" { + target.ServiceNames = append(target.ServiceNames, spec.ServiceName) + } + for _, imageAndModule := range spec.ImageAndModules { + if spec.ServiceName != "" { + target.ServiceNames = append(target.ServiceNames, spec.ServiceName) + } + if imageAndModule.Image != "" { + target.ImageVersions = append(target.ImageVersions, imageAndModule.Image) + } + target.TargetCount++ + } + target.ServiceNames = uniqueSortedStrings(target.ServiceNames) + target.ImageVersions = uniquePreserveOrder(target.ImageVersions) + if target.TargetCount == 0 && len(target.ServiceNames) > 0 { + target.TargetCount = len(target.ServiceNames) + } + return target +} + +func buildReleaseTargetFromHelmChartDeploy(spec *commonmodels.JobTaskHelmChartDeploySpec) *commonmodels.AIReleaseTargetsSummary { + target := &commonmodels.AIReleaseTargetsSummary{ + EnvName: spec.Env, + } + if spec.DeployHelmChart != nil { + target.TargetCount = 1 + if spec.DeployHelmChart.ReleaseName != "" { + target.ServiceNames = append(target.ServiceNames, spec.DeployHelmChart.ReleaseName) + } + if spec.DeployHelmChart.ChartVersion != "" { + target.ImageVersions = append(target.ImageVersions, spec.DeployHelmChart.ChartVersion) + } + } + target.ServiceNames = uniqueSortedStrings(target.ServiceNames) + target.ImageVersions = uniquePreserveOrder(target.ImageVersions) + return target +} + +func mergeReleaseTargets(targets []*commonmodels.AIReleaseTargetsSummary) *commonmodels.AIReleaseTargetsSummary { + merged := &commonmodels.AIReleaseTargetsSummary{} + for _, target := range targets { + if target == nil { + continue + } + if merged.EnvName == "" { + merged.EnvName = target.EnvName + } + if merged.EnvAlias == "" { + merged.EnvAlias = target.EnvAlias + } + if target.Production { + merged.Production = true + } + merged.ServiceNames = append(merged.ServiceNames, target.ServiceNames...) + merged.ImageVersions = append(merged.ImageVersions, target.ImageVersions...) + merged.TargetCount += target.TargetCount + } + merged.ServiceNames = uniqueSortedStrings(merged.ServiceNames) + merged.ImageVersions = uniquePreserveOrder(merged.ImageVersions) + if merged.TargetCount == 0 { + merged.TargetCount = len(merged.ServiceNames) + } + return merged +} + +func collectChangeSummaryFromFreestyleSpec(changeSummary *commonmodels.AIChangeSummary, spec *commonmodels.JobTaskFreestyleSpec) { + if changeSummary == nil || spec == nil { + return + } + for _, step := range spec.Steps { + if step.StepType != config.StepGit { + continue + } + stepSpec := &steptypes.StepGitSpec{} + if err := commonmodels.IToi(step.Spec, stepSpec); err != nil { + continue + } + for _, repo := range stepSpec.Repos { + if repo == nil { + continue + } + if repo.Branch != "" { + changeSummary.Branches = append(changeSummary.Branches, repo.Branch) + } + if repo.Tag != "" { + changeSummary.Tags = append(changeSummary.Tags, repo.Tag) + } + if repo.CommitMessage != "" { + changeSummary.CommitMessages = append(changeSummary.CommitMessages, compactSingleLine(repo.CommitMessage)) + } + if repo.RepoName != "" { + changeSummary.Services = append(changeSummary.Services, repo.RepoName) + } + } + } + for _, kv := range spec.Properties.Envs { + switch kv.Key { + case "SERVICE_NAME": + if kv.Value != "" { + changeSummary.Services = append(changeSummary.Services, kv.Value) + } + case "SERVICE_MODULE": + if kv.Value != "" { + changeSummary.Services = append(changeSummary.Services, kv.Value) + } + } + } +} + +func buildResultSummaryLine(job *commonmodels.JobTask) string { + if strings.TrimSpace(job.Error) != "" { + return fmt.Sprintf("%s(%s): %s", job.OriginName, job.Status, compactSingleLine(job.Error)) + } + return fmt.Sprintf("%s(%s)", job.OriginName, job.Status) +} + +func BuildAIReleaseSpecialistPrompt(promptTemplate string, input *commonmodels.AIReleaseSpecialistInput) (string, error) { + debugResult, err := BuildAIReleaseSpecialistPromptForDebug(promptTemplate, "", input) + if err != nil { + return "", err + } + if debugResult.PromptTooLarge { + return "", fmt.Errorf("prompt too large: %d tokens", debugResult.PromptTokens) + } + return debugResult.Prompt, nil +} + +func BuildAIReleaseSpecialistPromptForDebug(promptTemplate, systemPromptOverride string, input *commonmodels.AIReleaseSpecialistInput) (*AIReleaseSpecialistPromptDebugResult, error) { + inputJSON, err := json.MarshalIndent(input, "", " ") + if err != nil { + return nil, err + } + systemPrompt := buildAIReleaseSpecialistSystemPrompt(string(inputJSON), systemPromptOverride) + prompt := systemPrompt + if strings.TrimSpace(promptTemplate) != "" { + prompt = fmt.Sprintf("%s\n\n%s", strings.TrimSpace(promptTemplate), systemPrompt) + } + promptTokens := getAIReleaseSpecialistPromptTokens(prompt) + return &AIReleaseSpecialistPromptDebugResult{ + SystemPrompt: systemPrompt, + Prompt: prompt, + PromptTokens: promptTokens, + PromptTooLarge: promptTokens > aiReleaseSpecialistMaxPromptTokens, + }, nil +} + +func buildAIReleaseSpecialistSystemPrompt(inputJSON, systemPromptOverride string) string { + systemPrompt := strings.TrimSpace(systemPromptOverride) + if systemPrompt == "" { + systemPrompt = defaultAIReleaseSpecialistSystemPrompt + } + return fmt.Sprintf("%s\n\n发布上下文:\n```json\n%s\n```", systemPrompt, inputJSON) +} + +func getAIReleaseSpecialistPromptTokens(prompt string) int { + tokenNum, err := llm.NumTokensFromPrompt(prompt, "") + if err != nil { + return 0 + } + return tokenNum +} + +func ParseAIReleaseSpecialistResult(answer string) (*commonmodels.AIReleaseSpecialistResult, error) { + rawText := strings.TrimSpace(answer) + jsonText := extractJSONCodeBlock(rawText) + result := &commonmodels.AIReleaseSpecialistResult{} + if err := json.Unmarshal([]byte(jsonText), result); err != nil { + return nil, err + } + result.Conclusion = normalizeAIResultValue(result.Conclusion) + for _, check := range result.Checks { + if check == nil { + continue + } + check.Result = normalizeAIResultValue(check.Result) + check.Name = strings.TrimSpace(check.Name) + check.Evidence = strings.TrimSpace(check.Evidence) + check.Suggestion = strings.TrimSpace(check.Suggestion) + } + result.Summary = strings.TrimSpace(result.Summary) + result.RawText = rawText + if result.Conclusion == "" { + return nil, fmt.Errorf("empty conclusion") + } + return result, nil +} + +func extractJSONCodeBlock(text string) string { + trimmed := strings.TrimSpace(text) + if strings.HasPrefix(trimmed, "```json") { + trimmed = strings.TrimPrefix(trimmed, "```json") + trimmed = strings.TrimSpace(trimmed) + if strings.HasSuffix(trimmed, "```") { + trimmed = strings.TrimSuffix(trimmed, "```") + } + return strings.TrimSpace(trimmed) + } + if strings.HasPrefix(trimmed, "```") { + trimmed = strings.TrimPrefix(trimmed, "```") + trimmed = strings.TrimSpace(trimmed) + if strings.HasSuffix(trimmed, "```") { + trimmed = strings.TrimSuffix(trimmed, "```") + } + } + return strings.TrimSpace(trimmed) +} + +func normalizeAIResultValue(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "pass", "passed", "ok", "success": + return "pass" + case "warning", "warn": + return "warning" + case "fail", "failed", "error": + return "fail" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func writeAIReleaseSpecialistOutputs(workflowCtx *commonmodels.WorkflowTaskCtx, jobKey string, result *commonmodels.AIReleaseSpecialistResult) { + if workflowCtx == nil || result == nil { + return + } + resultJSONBytes, _ := json.Marshal(result) + workflowCtx.GlobalContextSet(runtimejob.GetJobOutputKey(jobKey, "RESULT_JSON"), string(resultJSONBytes)) + workflowCtx.GlobalContextSet(runtimejob.GetJobOutputKey(jobKey, "CONCLUSION"), result.Conclusion) + workflowCtx.GlobalContextSet(runtimejob.GetJobOutputKey(jobKey, "SUMMARY"), result.Summary) + workflowCtx.GlobalContextSet(runtimejob.GetJobOutputKey(jobKey, "CHECK_COUNT"), fmt.Sprintf("%d", len(result.Checks))) + workflowCtx.GlobalContextSet(runtimejob.GetJobOutputKey(jobKey, "CHECK_DETAILS_MARKDOWN"), renderCheckDetailsMarkdown(result.Checks)) +} + +func renderCheckDetailsMarkdown(checks []*commonmodels.AIReleaseSpecialistCheckItem) string { + if len(checks) == 0 { + return "" + } + lines := make([]string, 0, len(checks)*4) + for _, check := range checks { + if check == nil { + continue + } + lines = append(lines, fmt.Sprintf("- %s [%s]", safeMarkdownText(check.Name), safeMarkdownText(check.Result))) + if check.Evidence != "" { + lines = append(lines, fmt.Sprintf(" - 依据: %s", safeMarkdownText(check.Evidence))) + } + if check.Suggestion != "" { + lines = append(lines, fmt.Sprintf(" - 建议: %s", safeMarkdownText(check.Suggestion))) + } + } + return strings.Join(lines, "\n") +} + +func buildChangeSummaryText(changeSummary *commonmodels.AIChangeSummary) string { + if changeSummary == nil { + return "" + } + parts := make([]string, 0, 5) + if changeSummary.Remark != "" { + parts = append(parts, fmt.Sprintf("remark: %s", compactSingleLine(changeSummary.Remark))) + } + if len(changeSummary.Services) > 0 { + parts = append(parts, fmt.Sprintf("services: %s", strings.Join(changeSummary.Services, ", "))) + } + if len(changeSummary.Branches) > 0 { + parts = append(parts, fmt.Sprintf("branches: %s", strings.Join(changeSummary.Branches, ", "))) + } + if len(changeSummary.Tags) > 0 { + parts = append(parts, fmt.Sprintf("tags: %s", strings.Join(changeSummary.Tags, ", "))) + } + if len(changeSummary.CommitMessages) > 0 { + parts = append(parts, fmt.Sprintf("commits: %s", strings.Join(changeSummary.CommitMessages, " | "))) + } + return strings.Join(parts, "\n") +} + +func compactSingleLine(text string) string { + return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") +} + +func safeMarkdownText(text string) string { + return strings.ReplaceAll(compactSingleLine(text), "\n", " ") +} + +func uniqueSortedStrings(values []string) []string { + set := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + set[value] = struct{}{} + } + resp := make([]string, 0, len(set)) + for value := range set { + resp = append(resp, value) + } + sort.Strings(resp) + return resp +} + +func uniquePreserveOrder(values []string) []string { + seen := map[string]struct{}{} + resp := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + resp = append(resp, value) + } + return resp +} + +func getDefaultAIReleaseSpecialistLLMClient(ctx context.Context) (llm.ILLM, error) { + llmIntegration, err := mongodb.NewLLMIntegrationColl().FindDefault(ctx) + if err != nil { + return nil, fmt.Errorf("failed to find default llm integration, err: %w", err) + } + + llmConfig := llm.LLMConfig{ + ProviderName: llmIntegration.ProviderName, + Token: llmIntegration.Token, + BaseURL: llmIntegration.BaseURL, + Model: llmIntegration.Model, + } + if llmIntegration.EnableProxy { + llmConfig.Proxy = config.ProxyHTTPSAddr() + } + + llmClient, err := llm.NewClient(llmConfig.ProviderName) + if err != nil { + return nil, fmt.Errorf("could not create the llm client for %s: %w", llmConfig.ProviderName, err) + } + if err := llmClient.Configure(llmConfig); err != nil { + return nil, fmt.Errorf("could not configure the llm client for %s: %w", llmConfig.ProviderName, err) + } + return llmClient, nil +} diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist_test.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist_test.go new file mode 100644 index 0000000000..c4709da477 --- /dev/null +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_ai_release_specialist_test.go @@ -0,0 +1,79 @@ +package jobcontroller + +import ( + "testing" + "time" + + commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" +) + +func TestNormalizeAIResultValue(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "pass", input: "pass", want: "pass"}, + {name: "warning", input: "warning", want: "warning"}, + {name: "fail", input: "fail", want: "fail"}, + {name: "failed alias", input: "FAILED", want: "fail"}, + {name: "warn alias", input: "warn", want: "warning"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeAIResultValue(tt.input); got != tt.want { + t.Fatalf("normalizeAIResultValue(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseAIReleaseSpecialistResultPreservesFailConclusion(t *testing.T) { + answer := `{"conclusion":"fail","summary":"存在高风险变更","checks":[{"name":"变更检查","result":"fail","evidence":"涉及生产环境核心服务","suggestion":"停止发布"}]}` + + result, err := ParseAIReleaseSpecialistResult(answer) + if err != nil { + t.Fatalf("ParseAIReleaseSpecialistResult returned error: %v", err) + } + if result.Conclusion != "fail" { + t.Fatalf("result.Conclusion = %q, want fail", result.Conclusion) + } + if result.Checks[0].Result != "fail" { + t.Fatalf("result.Checks[0].Result = %q, want fail", result.Checks[0].Result) + } +} + +func TestBuildAIReleaseSpecialistPromptForDebug(t *testing.T) { + input := &commonmodels.AIReleaseSpecialistInput{ + ChangeSummary: &commonmodels.AIChangeSummary{ + Remark: "本次发布包含 2 个服务变更", + }, + } + + result, err := BuildAIReleaseSpecialistPromptForDebug("请重点关注生产风险。", "你是一个谨慎的发布助手。", input) + if err != nil { + t.Fatalf("BuildAIReleaseSpecialistPromptForDebug returned error: %v", err) + } + if result.SystemPrompt == "" { + t.Fatal("result.SystemPrompt is empty") + } + if result.Prompt == "" { + t.Fatal("result.Prompt is empty") + } + if result.PromptTokens <= 0 { + t.Fatalf("result.PromptTokens = %d, want > 0", result.PromptTokens) + } +} + +func TestGetRemainingTimeout(t *testing.T) { + ctl := &AIReleaseSpecialistJobCtl{ + jobTaskSpec: &commonmodels.JobTaskAIReleaseSpecialistSpec{ + Timeout: 10, + }, + } + remaining := ctl.getRemainingTimeout(time.Now().Add(-2 * time.Minute)) + if remaining > 8 || remaining < 7 { + t.Fatalf("remaining timeout = %d, want around 8", remaining) + } +} diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_approval.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_approval.go index fafb5d335d..1772625067 100644 --- a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_approval.go +++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_approval.go @@ -125,6 +125,9 @@ func waitForNativeApprove(ctx context.Context, spec *commonmodels.JobTaskApprova time.Sleep(1 * time.Second) select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return config.StatusTimeout, fmt.Errorf("workflow timeout") + } return config.StatusCancelled, fmt.Errorf("workflow was canceled") case <-timeoutChan: return config.StatusTimeout, fmt.Errorf("workflow timeout") @@ -258,6 +261,10 @@ func waitForLarkApprove(ctx context.Context, spec *commonmodels.JobTaskApprovalS time.Sleep(2 * time.Second) select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + cancelApproval() + return config.StatusTimeout, fmt.Errorf("workflow timeout") + } cancelApproval() return config.StatusCancelled, fmt.Errorf("workflow was canceled") case <-timeoutChan: @@ -432,6 +439,9 @@ func waitForDingTalkApprove(ctx context.Context, spec *commonmodels.JobTaskAppro time.Sleep(1 * time.Second) select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return config.StatusTimeout, fmt.Errorf("workflow timeout") + } return config.StatusCancelled, fmt.Errorf("workflow was canceled") case <-timeoutChan: return config.StatusTimeout, fmt.Errorf("workflow timeout") @@ -685,6 +695,9 @@ func waitForWorkWXApprove(ctx context.Context, spec *commonmodels.JobTaskApprova time.Sleep(1 * time.Second) select { case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return config.StatusTimeout, fmt.Errorf("workflow timeout") + } return config.StatusCancelled, fmt.Errorf("workflow was canceled") case <-timeoutChan: return config.StatusTimeout, fmt.Errorf("workflow timeout") diff --git a/pkg/microservice/aslan/core/workflow/handler/router.go b/pkg/microservice/aslan/core/workflow/handler/router.go index 91d0e0b10d..1c04f017ec 100644 --- a/pkg/microservice/aslan/core/workflow/handler/router.go +++ b/pkg/microservice/aslan/core/workflow/handler/router.go @@ -65,6 +65,7 @@ func (*Router) Inject(router *gin.RouterGroup) { workflowV4 := router.Group("v4") { workflowV4.POST("", CreateWorkflowV4) + workflowV4.POST("/ai-release-specialist/debug", DebugAIReleaseSpecialistPrompt) workflowV4.POST("/workflowtask/:workflowName/field", SetWorkflowTasksCustomFields) workflowV4.GET("/workflowtask/:workflowName/field", GetWorkflowTasksCustomFields) workflowV4.GET("", ListWorkflowV4) diff --git a/pkg/microservice/aslan/core/workflow/handler/workflow_v4.go b/pkg/microservice/aslan/core/workflow/handler/workflow_v4.go index a6965ff528..3d262fb3b6 100644 --- a/pkg/microservice/aslan/core/workflow/handler/workflow_v4.go +++ b/pkg/microservice/aslan/core/workflow/handler/workflow_v4.go @@ -72,6 +72,25 @@ type listWorkflowV4Resp struct { Total int64 `json:"total"` } +func DebugAIReleaseSpecialistPrompt(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 + } + + req := new(workflow.DebugAIReleaseSpecialistPromptRequest) + if err := c.ShouldBindJSON(req); err != nil { + ctx.RespErr = e.ErrInvalidParam.AddDesc(err.Error()) + return + } + + ctx.Resp, ctx.RespErr = workflow.DebugAIReleaseSpecialistPrompt(ctx, req, ctx.Logger) +} + // @Summary 创建工作流 // @Description 创建工作流 // @Tags workflow diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug.go b/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug.go new file mode 100644 index 0000000000..a34ae7e557 --- /dev/null +++ b/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug.go @@ -0,0 +1,202 @@ +/* +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 workflow + +import ( + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + commonrepo "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" + commonservice "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller" + "github.com/koderover/zadig/v2/pkg/shared/handler" + e "github.com/koderover/zadig/v2/pkg/tool/errors" + "github.com/koderover/zadig/v2/pkg/tool/llm" + "github.com/koderover/zadig/v2/pkg/types" +) + +type DebugAIReleaseSpecialistPromptRequest struct { + WorkflowName string `json:"workflow_name"` + TaskID int64 `json:"task_id"` + JobName string `json:"job_name"` + PromptTemplate string `json:"prompt_template"` + SystemPromptOverride string `json:"system_prompt_override"` + Execute bool `json:"execute"` +} + +type DebugAIReleaseSpecialistPromptResponse struct { + WorkflowName string `json:"workflow_name,omitempty"` + TaskID int64 `json:"task_id,omitempty"` + JobName string `json:"job_name,omitempty"` + PromptTemplate string `json:"prompt_template,omitempty"` + EffectiveInput *commonmodels.AIReleaseSpecialistInput `json:"effective_input,omitempty"` + EffectiveSystemPrompt string `json:"effective_system_prompt"` + FinalPrompt string `json:"final_prompt"` + PromptTokens int `json:"prompt_tokens"` + PromptTooLarge bool `json:"prompt_too_large"` + Model string `json:"model,omitempty"` + RawResponse string `json:"raw_response,omitempty"` + ParsedResult *commonmodels.AIReleaseSpecialistResult `json:"parsed_result,omitempty"` + ParseError string `json:"parse_error,omitempty"` + LLMError string `json:"llm_error,omitempty"` +} + +func DebugAIReleaseSpecialistPrompt(ctx *handler.Context, req *DebugAIReleaseSpecialistPromptRequest, logger *zap.SugaredLogger) (*DebugAIReleaseSpecialistPromptResponse, error) { + if req == nil { + return nil, e.ErrInvalidParam.AddDesc("request cannot be nil") + } + if err := validateAIReleaseSpecialistDebugRequest(req); err != nil { + return nil, e.ErrInvalidParam.AddDesc(err.Error()) + } + + workflowName := strings.TrimSpace(req.WorkflowName) + taskID := req.TaskID + jobName := strings.TrimSpace(req.JobName) + + task, err := commonrepo.NewworkflowTaskv4Coll().Find(workflowName, taskID) + if err != nil { + logger.Errorf("find workflow task failed, workflow: %s, taskID: %d, err: %v", workflowName, taskID, err) + return nil, e.ErrFindWorkflow.AddErr(err) + } + if err := ensureWorkflowPermission(ctx, task.ProjectName, workflowName); err != nil { + return nil, err + } + + input, err := jobcontroller.BuildAIReleaseSpecialistInputFromTask(task, jobName) + if err != nil { + return nil, e.ErrInvalidParam.AddDesc(fmt.Sprintf("build ai release specialist input failed: %v", err)) + } + promptTemplate, err := getAIReleaseSpecialistPromptTemplate(workflowName, jobName, req.PromptTemplate, logger) + if err != nil { + return nil, err + } + + debugResult, err := jobcontroller.BuildAIReleaseSpecialistPromptForDebug(promptTemplate, req.SystemPromptOverride, input) + if err != nil { + return nil, e.ErrInvalidParam.AddDesc(fmt.Sprintf("build prompt failed: %v", err)) + } + + resp := &DebugAIReleaseSpecialistPromptResponse{ + WorkflowName: workflowName, + TaskID: taskID, + JobName: jobName, + PromptTemplate: promptTemplate, + EffectiveInput: input, + EffectiveSystemPrompt: debugResult.SystemPrompt, + FinalPrompt: debugResult.Prompt, + PromptTokens: debugResult.PromptTokens, + PromptTooLarge: debugResult.PromptTooLarge, + } + + if !req.Execute { + return resp, nil + } + + llmClient, err := commonservice.GetDefaultLLMClient(ctx.Context) + if err != nil { + resp.LLMError = err.Error() + return resp, nil + } + + options := []llm.ParamOption{ + llm.WithTemperature(0.1), + llm.WithMaxTokens(3000), + } + if llmClient.GetModel() != "" { + resp.Model = llmClient.GetModel() + options = append(options, llm.WithModel(llmClient.GetModel())) + } + + answer, err := llmClient.GetCompletion(ctx.Context, debugResult.Prompt, options...) + if err != nil { + resp.LLMError = err.Error() + return resp, nil + } + resp.RawResponse = answer + + parsedResult, err := jobcontroller.ParseAIReleaseSpecialistResult(answer) + if err != nil { + resp.ParseError = err.Error() + return resp, nil + } + resp.ParsedResult = parsedResult + return resp, nil +} + +func validateAIReleaseSpecialistDebugRequest(req *DebugAIReleaseSpecialistPromptRequest) error { + if strings.TrimSpace(req.WorkflowName) == "" || req.TaskID <= 0 || strings.TrimSpace(req.JobName) == "" { + return fmt.Errorf("workflow_name, task_id and job_name are required") + } + return nil +} + +func getAIReleaseSpecialistPromptTemplate(workflowName, jobName, override string, logger *zap.SugaredLogger) (string, error) { + if strings.TrimSpace(override) != "" { + return strings.TrimSpace(override), nil + } + + workflow, err := FindWorkflowV4Raw(workflowName, logger) + if err != nil { + return "", err + } + job, err := workflow.FindJob(jobName, "") + if err != nil { + return "", e.ErrFindWorkflow.AddDesc(err.Error()) + } + if job.JobType != config.JobAIReleaseSpecialist { + return "", e.ErrInvalidParam.AddDesc(fmt.Sprintf("job %s is not ai release specialist", jobName)) + } + + spec := new(commonmodels.AIReleaseSpecialistJobSpec) + if err := commonmodels.IToi(job.Spec, spec); err != nil { + return "", e.ErrInvalidParam.AddDesc(fmt.Sprintf("decode ai release specialist job spec failed: %v", err)) + } + if strings.TrimSpace(spec.PromptTemplate) == "" { + return "", e.ErrInvalidParam.AddDesc("prompt template cannot be empty") + } + return strings.TrimSpace(spec.PromptTemplate), nil +} + +func ensureWorkflowPermission(ctx *handler.Context, projectName, workflowName string) error { + if ctx == nil || ctx.Resources == nil { + return e.ErrUnauthorized + } + if ctx.Resources.IsSystemAdmin { + return nil + } + + projectAuth, ok := ctx.Resources.ProjectAuthInfo[projectName] + if !ok { + return e.ErrForbidden + } + if projectAuth.IsProjectAdmin { + return nil + } + if projectAuth.Workflow != nil && (projectAuth.Workflow.View || projectAuth.Workflow.Edit) { + return nil + } + + permitted, err := handler.GetCollaborationModePermission(ctx.UserID, projectName, types.ResourceTypeWorkflow, workflowName, types.WorkflowActionView) + if err == nil && permitted { + return nil + } + return e.ErrForbidden +} diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug_test.go b/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug_test.go new file mode 100644 index 0000000000..36234b5dad --- /dev/null +++ b/pkg/microservice/aslan/core/workflow/service/workflow/ai_release_specialist_debug_test.go @@ -0,0 +1,67 @@ +package workflow + +import ( + "testing" + + "github.com/koderover/zadig/v2/pkg/shared/client/user" + internalhandler "github.com/koderover/zadig/v2/pkg/shared/handler" +) + +func TestValidateAIReleaseSpecialistDebugRequest(t *testing.T) { + tests := []struct { + name string + req *DebugAIReleaseSpecialistPromptRequest + wantErr bool + }{ + { + name: "task", + req: &DebugAIReleaseSpecialistPromptRequest{ + WorkflowName: "wf", + TaskID: 1, + JobName: "ai-check", + }, + }, + { + name: "missing job name", + req: &DebugAIReleaseSpecialistPromptRequest{ + WorkflowName: "wf", + TaskID: 1, + }, + wantErr: true, + }, + { + name: "invalid task mode", + req: &DebugAIReleaseSpecialistPromptRequest{ + WorkflowName: "wf", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAIReleaseSpecialistDebugRequest(tt.req) + if (err != nil) != tt.wantErr { + t.Fatalf("validateAIReleaseSpecialistDebugRequest() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestEnsureWorkflowPermission(t *testing.T) { + ctx := &internalhandler.Context{ + UserID: "user-1", + Resources: &user.AuthorizedResources{ + ProjectAuthInfo: map[string]*user.ProjectActions{ + "demo": { + Workflow: &user.WorkflowActions{ + View: true, + }, + }, + }, + }, + } + if err := ensureWorkflowPermission(ctx, "demo", "wf-demo"); err != nil { + t.Fatalf("ensureWorkflowPermission returned error: %v", err) + } +} diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/interface.go b/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/interface.go index b998b75a09..2ad3d706c5 100644 --- a/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/interface.go +++ b/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/interface.go @@ -80,6 +80,8 @@ func CreateJobController(job *commonmodels.Job, workflow *commonmodels.WorkflowV return CreateApolloJobController(job, workflow) case config.JobApproval: return CreateApprovalJobController(job, workflow) + case config.JobAIReleaseSpecialist: + return CreateAIReleaseSpecialistJobController(job, workflow) case config.JobK8sBlueGreenDeploy: return CreateBlueGreenDeployJobController(job, workflow) case config.JobK8sBlueGreenRelease: diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/job_ai_release_specialist.go b/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/job_ai_release_specialist.go new file mode 100644 index 0000000000..47284cb0c1 --- /dev/null +++ b/pkg/microservice/aslan/core/workflow/service/workflow/controller/job/job_ai_release_specialist.go @@ -0,0 +1,197 @@ +package job + +import ( + "fmt" + "strings" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + commonmodels "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/setting" + "github.com/koderover/zadig/v2/pkg/types" +) + +const ( + AIReleaseSpecialistOutputResultJSON = "RESULT_JSON" + AIReleaseSpecialistOutputConclusion = "CONCLUSION" + AIReleaseSpecialistOutputSummary = "SUMMARY" + AIReleaseSpecialistOutputCheckCount = "CHECK_COUNT" + AIReleaseSpecialistOutputCheckDetailsMarkdown = "CHECK_DETAILS_MARKDOWN" +) + +type AIReleaseSpecialistJobController struct { + *BasicInfo + + jobSpec *commonmodels.AIReleaseSpecialistJobSpec +} + +func CreateAIReleaseSpecialistJobController(job *commonmodels.Job, workflow *commonmodels.WorkflowV4) (Job, error) { + spec := new(commonmodels.AIReleaseSpecialistJobSpec) + if err := commonmodels.IToi(job.Spec, spec); err != nil { + return nil, fmt.Errorf("failed to create ai release specialist job controller, error: %s", err) + } + + basicInfo := &BasicInfo{ + name: job.Name, + jobType: job.JobType, + errorPolicy: job.ErrorPolicy, + executePolicy: job.ExecutePolicy, + workflow: workflow, + } + + return AIReleaseSpecialistJobController{ + BasicInfo: basicInfo, + jobSpec: spec, + }, nil +} + +func (j AIReleaseSpecialistJobController) SetWorkflow(wf *commonmodels.WorkflowV4) { + j.workflow = wf +} + +func (j AIReleaseSpecialistJobController) GetSpec() interface{} { + return j.jobSpec +} + +func (j AIReleaseSpecialistJobController) Validate(isExecution bool) error { + if strings.TrimSpace(j.jobSpec.PromptTemplate) == "" { + return fmt.Errorf("prompt template cannot be empty") + } + if j.jobSpec.RequireManualConfirm && len(j.jobSpec.ConfirmUsers) == 0 { + return fmt.Errorf("confirm users cannot be empty when manual confirm is enabled") + } + for _, user := range j.jobSpec.ConfirmUsers { + if user == nil { + return fmt.Errorf("confirm user cannot be nil") + } + switch user.Type { + case "", setting.UserTypeUser: + if user.UserID == "" { + return fmt.Errorf("confirm user id cannot be empty") + } + case setting.UserTypeGroup: + if user.GroupID == "" { + return fmt.Errorf("confirm group id cannot be empty") + } + case setting.UserTypeTaskCreator: + default: + return fmt.Errorf("confirm user type %s is not supported", user.Type) + } + } + return nil +} + +func (j AIReleaseSpecialistJobController) Update(useUserInput bool, ticket *commonmodels.ApprovalTicket) error { + currJob, err := j.workflow.FindJob(j.name, j.jobType) + if err != nil { + return err + } + + currJobSpec := new(commonmodels.AIReleaseSpecialistJobSpec) + if err := commonmodels.IToi(currJob.Spec, currJobSpec); err != nil { + return fmt.Errorf("failed to decode ai release specialist job spec, error: %s", err) + } + + j.errorPolicy = currJob.ErrorPolicy + j.executePolicy = currJob.ExecutePolicy + j.jobSpec.Timeout = currJobSpec.Timeout + j.jobSpec.PromptTemplate = currJobSpec.PromptTemplate + j.jobSpec.RequireManualConfirm = currJobSpec.RequireManualConfirm + j.jobSpec.ConfirmUsers = currJobSpec.ConfirmUsers + return nil +} + +func (j AIReleaseSpecialistJobController) SetOptions(ticket *commonmodels.ApprovalTicket) error { + return nil +} + +func (j AIReleaseSpecialistJobController) ClearOptions() {} + +func (j AIReleaseSpecialistJobController) ClearSelection() {} + +func (j AIReleaseSpecialistJobController) ToTask(taskID int64) ([]*commonmodels.JobTask, error) { + spec := &commonmodels.JobTaskAIReleaseSpecialistSpec{ + Timeout: j.jobSpec.Timeout, + PromptTemplate: j.jobSpec.PromptTemplate, + RequireManualConfirm: j.jobSpec.RequireManualConfirm, + ConfirmUsers: j.jobSpec.ConfirmUsers, + } + if j.jobSpec.RequireManualConfirm { + spec.NativeApproval = &commonmodels.NativeApproval{ + ApproveUsers: j.jobSpec.ConfirmUsers, + NeededApprovers: 1, + Timeout: int(j.jobSpec.Timeout), + } + } + + jobTask := &commonmodels.JobTask{ + Name: GenJobName(j.workflow, j.name, 0), + Key: genJobKey(j.name), + DisplayName: genJobDisplayName(j.name), + OriginName: j.name, + JobInfo: map[string]string{ + JobNameKey: j.name, + }, + JobType: string(config.JobAIReleaseSpecialist), + Spec: spec, + Timeout: j.jobSpec.Timeout, + ErrorPolicy: j.errorPolicy, + ExecutePolicy: j.executePolicy, + Outputs: []*commonmodels.Output{ + {Name: AIReleaseSpecialistOutputResultJSON, Description: "AI 发布专员结构化结果 JSON"}, + {Name: AIReleaseSpecialistOutputConclusion, Description: "AI 发布专员结论"}, + {Name: AIReleaseSpecialistOutputSummary, Description: "AI 发布专员摘要"}, + {Name: AIReleaseSpecialistOutputCheckCount, Description: "AI 发布专员检测项数量"}, + {Name: AIReleaseSpecialistOutputCheckDetailsMarkdown, Description: "AI 发布专员检测项 Markdown"}, + }, + } + + return []*commonmodels.JobTask{jobTask}, nil +} + +func (j AIReleaseSpecialistJobController) SetRepo(repo *types.Repository) error { + return nil +} + +func (j AIReleaseSpecialistJobController) SetRepoCommitInfo() error { + return nil +} + +func (j AIReleaseSpecialistJobController) GetVariableList(jobName string, getAggregatedVariables, getRuntimeVariables, getPlaceHolderVariables, getServiceSpecificVariables, useUserInputValue bool) ([]*commonmodels.KeyVal, error) { + resp := make([]*commonmodels.KeyVal, 0) + if getRuntimeVariables { + resp = append(resp, &commonmodels.KeyVal{ + Key: strings.Join([]string{"job", j.name, "status"}, "."), + Value: "", + Type: "string", + IsCredential: false, + }) + outputs := []string{ + AIReleaseSpecialistOutputResultJSON, + AIReleaseSpecialistOutputConclusion, + AIReleaseSpecialistOutputSummary, + AIReleaseSpecialistOutputCheckCount, + AIReleaseSpecialistOutputCheckDetailsMarkdown, + } + for _, output := range outputs { + resp = append(resp, &commonmodels.KeyVal{ + Key: strings.Join([]string{"job", j.name, "output", output}, "."), + Value: "", + Type: "string", + IsCredential: false, + }) + } + } + return resp, nil +} + +func (j AIReleaseSpecialistJobController) GetUsedRepos() ([]*types.Repository, error) { + return make([]*types.Repository, 0), nil +} + +func (j AIReleaseSpecialistJobController) RenderDynamicVariableOptions(key string, option *RenderDynamicVariableValue) ([]string, error) { + return nil, fmt.Errorf("invalid job type: %s to render dynamic variable", j.name) +} + +func (j AIReleaseSpecialistJobController) IsServiceTypeJob() bool { + return false +} diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/openapi.go b/pkg/microservice/aslan/core/workflow/service/workflow/openapi.go index 251e387451..0cfa83dcd3 100644 --- a/pkg/microservice/aslan/core/workflow/service/workflow/openapi.go +++ b/pkg/microservice/aslan/core/workflow/service/workflow/openapi.go @@ -398,6 +398,10 @@ func GetInputUpdater(job *commonmodels.Job, input interface{}, workflow *commonm case config.JobApproval: updater := new(ApprovalJobInput) return updater, nil + case config.JobAIReleaseSpecialist: + updater := new(EmptyInput) + err := commonmodels.IToi(input, updater) + return updater, err case config.JobSQL: updater := new(SQLJobInput) err := commonmodels.IToi(input, updater) diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/workflow_task_v4.go b/pkg/microservice/aslan/core/workflow/service/workflow/workflow_task_v4.go index 19b3ae813d..c1d8240dbb 100644 --- a/pkg/microservice/aslan/core/workflow/service/workflow/workflow_task_v4.go +++ b/pkg/microservice/aslan/core/workflow/service/workflow/workflow_task_v4.go @@ -2964,6 +2964,12 @@ func jobsToJobPreviews(jobs []*commonmodels.JobTask, context map[string]string, sepc.ClusterName = cluster.Name } jobPreview.Spec = sepc + case string(config.JobAIReleaseSpecialist): + spec := &commonmodels.JobTaskAIReleaseSpecialistSpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + continue + } + jobPreview.Spec = spec default: jobPreview.Spec = job.Spec } diff --git a/pkg/microservice/aslan/core/workflow/service/workflow/workflow_v4.go b/pkg/microservice/aslan/core/workflow/service/workflow/workflow_v4.go index faff2421f8..9e2dd1d451 100644 --- a/pkg/microservice/aslan/core/workflow/service/workflow/workflow_v4.go +++ b/pkg/microservice/aslan/core/workflow/service/workflow/workflow_v4.go @@ -1536,6 +1536,15 @@ func ensureWorkflowV4JobResp(job *commonmodels.Job, logger *zap.SugaredLogger, b job.Spec = spec } + + if job.JobType == config.JobAIReleaseSpecialist { + spec := &commonmodels.AIReleaseSpecialistJobSpec{} + if err := commonmodels.IToi(job.Spec, spec); err != nil { + logger.Errorf(err.Error()) + return e.ErrFindWorkflow.AddErr(err) + } + job.Spec = spec + } return nil }