package controller import ( "context" "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "html" "io" "net/http" "net/url" "os/exec" "regexp" "sort" "strconv" "strings" "time" "ezops/model" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const jenkinsPollInterval = 5 * time.Second type jenkinsClient struct { baseURL string username string token string client *http.Client crumb map[string]string } type jenkinsJob struct { Name string `json:"name"` URL string `json:"url"` Color string `json:"color"` Class string `json:"_class"` Description string `json:"description"` Jobs []jenkinsJob `json:"jobs"` Properties []struct { ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"` } `json:"property"` ViewName string `json:"-"` ViewURL string `json:"-"` Views []jenkinsJobView `json:"-"` LastBuild *struct { Number int `json:"number"` URL string `json:"url"` Timestamp int64 `json:"timestamp"` Duration int64 `json:"duration"` Building bool `json:"building"` Result string `json:"result"` } `json:"lastBuild"` } type jenkinsJobView struct { Name string `json:"name"` URL string `json:"url"` } type jenkinsParameterDefinition struct { Class string `json:"_class,omitempty"` Name string `json:"name"` Type string `json:"type"` Description string `json:"description"` Choices []string `json:"choices,omitempty"` Default interface{} `json:"default,omitempty"` DefaultParameterValue *struct { Value interface{} `json:"value"` } `json:"defaultParameterValue,omitempty"` } type jenkinsJobTree struct { Name string `json:"name"` URL string `json:"url"` Color string `json:"color"` Class string `json:"_class"` Description string `json:"description"` Jobs []jenkinsJob `json:"jobs"` Properties []struct { ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"` } `json:"property"` Views []jenkinsView `json:"views"` LastBuild *struct { Number int `json:"number"` URL string `json:"url"` Timestamp int64 `json:"timestamp"` Duration int64 `json:"duration"` Building bool `json:"building"` Result string `json:"result"` } `json:"lastBuild"` } type jenkinsView struct { Name string `json:"name"` URL string `json:"url"` Jobs []jenkinsJob `json:"jobs"` } type jenkinsQueueItem struct { ID int `json:"id"` Cancelled bool `json:"cancelled"` Why string `json:"why"` Executable *struct { Number int `json:"number"` URL string `json:"url"` } `json:"executable"` } type jenkinsBuildInfo struct { Building bool `json:"building"` Result string `json:"result"` Number int `json:"number"` URL string `json:"url"` Timestamp int64 `json:"timestamp"` Duration int64 `json:"duration"` } type jenkinsJobMetadata struct { Source string `json:"source,omitempty"` DefaultBranch string `json:"default_branch,omitempty"` ShellSteps []string `json:"shell_steps,omitempty"` Parameters []jenkinsParameterDefinition } func normalizeCIProvider(provider string) string { if strings.EqualFold(provider, "jenkins") { return "jenkins" } return "aiops" } func normalizeJenkinsRefMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "branch", "tag", "both": return strings.ToLower(strings.TrimSpace(mode)) default: return "both" } } func normalizeJenkinsRefParam(param string) string { param = strings.TrimSpace(param) if param == "" { return "SELECT" } return param } func (h *PipelineHandler) getJenkinsConfig() (model.JenkinsConfig, string, error) { var cfg model.JenkinsConfig if err := h.db.Order("id ASC").First(&cfg).Error; err != nil { return cfg, "", err } token := "" if cfg.Token != "" { plain, err := h.crypt.Decrypt(cfg.Token) if err != nil { return cfg, "", err } token = plain } return cfg, token, nil } func (h *PipelineHandler) jenkinsConfigPayload(cfg model.JenkinsConfig) gin.H { return gin.H{ "id": cfg.ID, "base_url": cfg.BaseURL, "username": cfg.Username, "auth_type": cfg.AuthType, "enabled": cfg.Enabled, "ref_mode": normalizeJenkinsRefMode(cfg.RefMode), "ref_param": normalizeJenkinsRefParam(cfg.RefParam), "token_set": cfg.Token != "", "last_sync_at": cfg.LastSyncAt, "created_at": cfg.CreatedAt, "updated_at": cfg.UpdatedAt, } } // GetJenkinsConfig 获取 Jenkins 接入配置,密钥不回显。 // Logan.Li func (h *PipelineHandler) GetJenkinsConfig(c *gin.Context) { cfg, _, err := h.getJenkinsConfig() if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"enabled": false, "token_set": false}}) return } c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: h.jenkinsConfigPayload(cfg)}) } // SaveJenkinsConfig 保存 Jenkins 接入配置,Token 为空时保留旧值。 // Logan.Li func (h *PipelineHandler) SaveJenkinsConfig(c *gin.Context) { var req model.JenkinsConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) return } baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/") if _, err := url.ParseRequestURI(baseURL); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 地址格式不正确"}) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } authType := req.AuthType if authType == "" { authType = "token" } var cfg model.JenkinsConfig err := h.db.Order("id ASC").First(&cfg).Error if err != nil && err != gorm.ErrRecordNotFound { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"}) return } tokenCipher := cfg.Token if req.Token != "" { enc, encErr := h.crypt.Encrypt(req.Token) if encErr != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "加密 Jenkins Token 失败"}) return } tokenCipher = enc } cfg.BaseURL = baseURL cfg.Username = strings.TrimSpace(req.Username) cfg.Token = tokenCipher cfg.AuthType = authType cfg.Enabled = enabled cfg.RefMode = normalizeJenkinsRefMode(req.RefMode) cfg.RefParam = normalizeJenkinsRefParam(req.RefParam) if cfg.ID == 0 { err = h.db.Create(&cfg).Error } else { err = h.db.Save(&cfg).Error } if err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存 Jenkins 配置失败"}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: h.jenkinsConfigPayload(cfg)}) } // TestJenkinsConfig 测试 Jenkins 连接。 // Logan.Li func (h *PipelineHandler) TestJenkinsConfig(c *gin.Context) { cfg, token, err := h.getJenkinsConfig() if err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请先保存 Jenkins 配置"}) return } client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) if err := client.ensureCrumb(c.Request.Context()); err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 连接失败: " + err.Error()}) return } c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{"base_url": cfg.BaseURL, "crumb": client.crumb != nil}}) } // SyncJenkinsJobs 扫描 Jenkins Jobs 并同步成 AIOps 流水线。 // Logan.Li func (h *PipelineHandler) SyncJenkinsJobs(c *gin.Context) { var req model.JenkinsSyncRequest _ = c.ShouldBindJSON(&req) cfg, token, err := h.getJenkinsConfig() if err != nil || !cfg.Enabled { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"}) return } client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) jobs, err := client.listJobs(c.Request.Context()) if err != nil { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "同步 Jenkins Jobs 失败: " + err.Error()}) return } created, updated, failed := 0, 0, 0 for _, job := range jobs { meta := client.jobMetadata(c.Request.Context(), job.URL) jobViewsJSON := encodeJenkinsJobViews(job.Views) jobParamsJSON := encodeJenkinsParameters(mergeJenkinsParameters(job.Parameters(), meta.Parameters)) jobStepsJSON := encodeJenkinsSteps(meta.ShellSteps) jobLastRunAt := jenkinsLastBuildTime(job) jobBranch := meta.DefaultBranch if jobBranch == "" { jobBranch = "main" } code := pipelineCodeFromJenkinsJob(req.Prefix, job.Name) uniqueCode := h.uniquePipelineCode(code) var p model.Pipeline err := h.db.Where("ci_provider = ? AND (jenkins_job_url = ? OR jenkins_job_name = ?)", "jenkins", job.URL, job.Name).First(&p).Error if err == gorm.ErrRecordNotFound { p = model.Pipeline{ Name: job.Name, Code: uniqueCode, JobName: uniqueCode, Description: job.Description, Type: "build", CIProvider: "jenkins", Source: meta.Source, Branch: jobBranch, DeployMode: "tag", DeployTarget: "ssh", TriggerMode: "manual", MaxExecutors: 1, Enabled: true, JenkinsJobName: job.Name, JenkinsJobURL: job.URL, JenkinsView: job.ViewName, JenkinsViewURL: job.ViewURL, JenkinsViews: jobViewsJSON, JenkinsParams: jobParamsJSON, JenkinsSteps: jobStepsJSON, LastRunAt: jobLastRunAt, LastStatus: jenkinsColorToStatus(job.Color), } if err := h.db.Create(&p).Error; err == nil { created++ } else { failed++ } continue } if err != nil { failed++ continue } if req.Overwrite { p.Name = job.Name p.Description = job.Description } p.CIProvider = "jenkins" p.JenkinsJobName = job.Name p.JenkinsJobURL = job.URL p.JenkinsView = job.ViewName p.JenkinsViewURL = job.ViewURL p.JenkinsViews = jobViewsJSON p.JenkinsParams = jobParamsJSON p.JenkinsSteps = jobStepsJSON if meta.Source != "" { p.Source = meta.Source } if meta.DefaultBranch != "" { p.Branch = meta.DefaultBranch } if jobLastRunAt != nil { p.LastRunAt = jobLastRunAt } p.LastStatus = jenkinsColorToStatus(job.Color) if err := h.db.Save(&p).Error; err == nil { updated++ } else { failed++ } } now := time.Now() _ = h.db.Model(&model.JenkinsConfig{}).Where("id = ?", cfg.ID).Update("last_sync_at", &now).Error message := "同步完成" if len(jobs) == 0 { message = "同步完成,但未发现 Jenkins Jobs" } c.JSON(http.StatusOK, model.Response{Code: 200, Message: message, Data: gin.H{"created": created, "updated": updated, "failed": failed, "total": len(jobs), "views": countJenkinsViews(jobs)}}) } // GetJenkinsRefs 只读获取 Jenkins Job 关联 Git 仓库的分支或 Tag 候选,不修改 Jenkins 配置。 // Logan.Li func (h *PipelineHandler) GetJenkinsRefs(c *gin.Context) { code := strings.TrimSpace(c.Query("code")) refType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "branch"))) if refType != "tag" { refType = "branch" } var pipeline model.Pipeline q := h.db.Where("ci_provider = ?", "jenkins") if code != "" { q = q.Where("code = ? OR job_name = ? OR jenkins_job_name = ?", code, code, code) } else if id, err := strconv.ParseUint(c.Query("pipeline_id"), 10, 64); err == nil && id > 0 { q = q.Where("id = ?", id) } else { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少流水线编码"}) return } if err := q.First(&pipeline).Error; err != nil { c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "Jenkins 流水线不存在"}) return } source := strings.TrimSpace(pipeline.Source) if source == "" { if cfg, token, err := h.getJenkinsConfig(); err == nil && cfg.Enabled { client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) meta := client.jobMetadata(c.Request.Context(), pipeline.JenkinsJobURL) source = meta.Source if source != "" || meta.DefaultBranch != "" || len(meta.ShellSteps) > 0 { updates := map[string]interface{}{} if source != "" { updates["source"] = source } if meta.DefaultBranch != "" { updates["branch"] = meta.DefaultBranch } if len(meta.ShellSteps) > 0 { updates["jenkins_steps"] = encodeJenkinsSteps(meta.ShellSteps) } _ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(updates).Error } } } choices := jenkinsStaticRefChoices(pipeline, refType) if source != "" { refs, err := listGitRefs(c.Request.Context(), source, refType) if err == nil && len(refs) > 0 { choices = refs } } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"source": source, "type": refType, "refs": choices}}) } func (h *PipelineHandler) triggerJenkinsPipeline(c *gin.Context, pipeline model.Pipeline, req model.TriggerPipelineRequest) { cfg, token, err := h.getJenkinsConfig() if err != nil || !cfg.Enabled { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"}) return } var maxBuildNumber int h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID). Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber) ref := req.Tag if ref == "" { ref = req.Branch } if ref == "" { ref = pipeline.Branch } refMode := normalizeJenkinsRefMode(cfg.RefMode) if refMode == "tag" && strings.TrimSpace(req.Tag) == "" { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择 Tag"}) return } if refMode == "branch" && strings.TrimSpace(req.Branch) == "" { c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择分支"}) return } username, _ := c.Get("username") userStr, _ := username.(string) now := time.Now() run := model.PipelineRun{ PipelineID: pipeline.ID, BuildNumber: maxBuildNumber + 1, Status: "running", Branch: ref, ImageTag: req.Tag, TriggeredBy: userStr, StartedAt: &now, ExternalURL: pipeline.JenkinsJobURL, BuildNodeName: "Jenkins", } if err := h.db.Create(&run).Error; err != nil { c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "触发失败"}) return } client := newJenkinsClient(cfg.BaseURL, cfg.Username, token) params := map[string]string{} for k, v := range req.Variables { params[k] = v } if ref != "" { params[preferredJenkinsRefParam(pipeline, cfg.RefParam)] = ref } go h.executeJenkinsRun(c.Request.Context(), client, pipeline, run, params) c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已触发 Jenkins", Data: run}) } func (h *PipelineHandler) executeJenkinsRun(parent context.Context, client *jenkinsClient, pipeline model.Pipeline, run model.PipelineRun, params map[string]string) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour) defer cancel() logKey := fmt.Sprintf("pipeline:run:%d:logs", run.ID) logFn := h.jenkinsLogFn(ctx, logKey) start := time.Now() logFn("info", "Jenkins", ">>> 触发 Jenkins Job: "+pipeline.JenkinsJobName) queueURL, err := client.trigger(ctx, pipeline, params) if err != nil { h.finishJenkinsRun(run, pipeline, "failed", start, "", "", logKey, fmt.Sprintf("触发失败: %v", err)) return } _ = parent logFn("info", "Jenkins", ">>> Jenkins 队列: "+queueURL) build, err := client.waitQueue(ctx, queueURL, logFn) if err != nil { h.finishJenkinsRun(run, pipeline, "failed", start, "", queueURL, logKey, fmt.Sprintf("获取构建号失败: %v", err)) return } buildNumber := strconv.Itoa(build.Number) _ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]interface{}{ "external_run_id": buildNumber, "external_url": build.URL, "build_number": build.Number, }).Error logFn("info", "Jenkins", fmt.Sprintf(">>> Jenkins Build #%d: %s", build.Number, build.URL)) final, err := client.waitBuild(ctx, build.URL, logFn) if err != nil { h.finishJenkinsRun(run, pipeline, "failed", start, buildNumber, build.URL, logKey, fmt.Sprintf("轮询失败: %v", err)) return } status := "failed" if final.Result == "SUCCESS" { status = "success" } else if final.Result == "ABORTED" { status = "cancelled" } h.finishJenkinsRun(run, pipeline, status, start, buildNumber, final.URL, logKey, "Jenkins result: "+final.Result) } func (h *PipelineHandler) jenkinsLogFn(ctx context.Context, logKey string) func(string, string, string) { return func(level, stage, msg string) { entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg} if h.cache == nil { return } existing, _ := h.cache.Get(ctx, logKey) var entries []LogEntry if existing != "" { _ = json.Unmarshal([]byte(existing), &entries) } entries = append(entries, entry) buf, _ := json.Marshal(entries) _ = h.cache.Set(ctx, logKey, string(buf), 30*time.Minute) } } func (h *PipelineHandler) finishJenkinsRun(run model.PipelineRun, pipeline model.Pipeline, status string, start time.Time, externalID, externalURL, logKey, message string) { now := time.Now() duration := int(now.Sub(start).Seconds()) logFn := h.jenkinsLogFn(context.Background(), logKey) level := "info" if status == "failed" { level = "error" } logFn(level, "Jenkins", ">>> "+message) logs, _ := h.cache.Get(context.Background(), logKey) updates := map[string]interface{}{ "status": status, "duration": duration, "finished_at": &now, "logs": logs, } if externalID != "" { updates["external_run_id"] = externalID } if externalURL != "" { updates["external_url"] = externalURL } _ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates).Error pUpdates := map[string]interface{}{"last_status": status, "last_run_at": now, "run_count": gorm.Expr("run_count + ?", 1)} if status == "success" { pUpdates["success_count"] = gorm.Expr("success_count + ?", 1) } else if status == "failed" { pUpdates["fail_count"] = gorm.Expr("fail_count + ?", 1) } _ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(pUpdates).Error } func newJenkinsClient(baseURL, username, token string) *jenkinsClient { return &jenkinsClient{ baseURL: strings.TrimRight(baseURL, "/"), username: username, token: token, client: &http.Client{Timeout: 30 * time.Second}, } } func (c *jenkinsClient) request(ctx context.Context, method, rawURL string, form url.Values) (*http.Response, error) { var body *strings.Reader if form != nil { body = strings.NewReader(form.Encode()) } else { body = strings.NewReader("") } req, err := http.NewRequestWithContext(ctx, method, rawURL, body) if err != nil { return nil, err } if c.username != "" || c.token != "" { req.SetBasicAuth(c.username, c.token) } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } for k, v := range c.crumb { req.Header.Set(k, v) } return c.client.Do(req) } func (c *jenkinsClient) ensureCrumb(ctx context.Context) error { resp, err := c.request(ctx, http.MethodGet, c.baseURL+"/crumbIssuer/api/json", nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { return nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("crumb status %d", resp.StatusCode) } var data struct { Field string `json:"crumbRequestField"` Crumb string `json:"crumb"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return err } if data.Field != "" && data.Crumb != "" { c.crumb = map[string]string{data.Field: data.Crumb} } return nil } func (c *jenkinsClient) listJobs(ctx context.Context) ([]jenkinsJob, error) { if err := c.ensureCrumb(ctx); err != nil { return nil, err } visited := map[string]bool{} return c.listJobsAt(ctx, c.baseURL, 0, visited) } func (c *jenkinsClient) listJobsAt(ctx context.Context, rootURL string, depth int, visited map[string]bool) ([]jenkinsJob, error) { if depth > 8 { return nil, nil } apiURL := strings.TrimRight(rootURL, "/") + "/api/json?tree=" + url.QueryEscape("name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url]],views[name,url,jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result]]]") resp, err := c.request(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("jobs api status %d", resp.StatusCode) } var root jenkinsJobTree if err := json.NewDecoder(resp.Body).Decode(&root); err != nil { return nil, err } out := make([]jenkinsJob, 0) if len(root.Jobs) == 0 && isBuildableJenkinsJob(root.Name, root.URL, root.Color, root.Class, root.LastBuild != nil) { return []jenkinsJob{{ Name: root.Name, URL: root.URL, Color: root.Color, Class: root.Class, Description: root.Description, LastBuild: root.LastBuild, }}, nil } merged := map[string]jenkinsJob{} for _, job := range root.Jobs { children, err := c.collectJenkinsJob(ctx, job, depth, visited) if err != nil { return nil, err } mergeJenkinsJobs(merged, children...) } for _, view := range root.Views { for _, job := range view.Jobs { viewVisited := map[string]bool{} children, err := c.collectJenkinsJob(ctx, job, depth, viewVisited) if err != nil { return nil, err } for i := range children { children[i].ViewName = view.Name children[i].ViewURL = view.URL children[i].Views = appendJenkinsJobView(children[i].Views, jenkinsJobView{Name: view.Name, URL: view.URL}) } mergeJenkinsJobs(merged, children...) } } keys := make([]string, 0, len(merged)) for key := range merged { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { job := merged[key] out = append(out, job) } return out, nil } func (c *jenkinsClient) collectJenkinsJob(ctx context.Context, job jenkinsJob, depth int, visited map[string]bool) ([]jenkinsJob, error) { key := strings.TrimRight(job.URL, "/") if key == "" || visited[key] { return nil, nil } visited[key] = true if len(job.Jobs) > 0 || isContainerJenkinsJob(job.Class) { children, err := c.listJobsAt(ctx, job.URL, depth+1, visited) if err != nil { return nil, err } if len(children) > 0 { return children, nil } if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) { return nil, nil } } if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) { return nil, nil } return []jenkinsJob{job}, nil } func (c *jenkinsClient) jobMetadata(ctx context.Context, jobURL string) jenkinsJobMetadata { var meta jenkinsJobMetadata xmlText := c.configXML(ctx, jobURL) if xmlText == "" { return meta } meta.Source = firstXMLValue(xmlText, `(?s).*?(.*?)`) if meta.Source == "" { meta.Source = firstXMLValue(xmlText, `(?s)]*>.*?(.*?)`) } meta.DefaultBranch = normalizeJenkinsBranch(firstXMLValue(xmlText, `(?s).*?(.*?)`)) meta.ShellSteps = allXMLValues(xmlText, `(?s).*?(.*?)`) meta.Parameters = parseJenkinsConfigParameters(xmlText) if meta.Source == "" { meta.Source = sourceFromJenkinsParameters(meta.Parameters) } return meta } func (c *jenkinsClient) configXML(ctx context.Context, jobURL string) string { jobURL = strings.TrimRight(jobURL, "/") if jobURL == "" { return "" } resp, err := c.request(ctx, http.MethodGet, jobURL+"/config.xml", nil) if err != nil { return "" } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "" } b, _ := io.ReadAll(resp.Body) return string(b) } func firstXMLValue(raw, pattern string) string { values := allXMLValues(raw, pattern) if len(values) == 0 { return "" } return values[0] } func allXMLValues(raw, pattern string) []string { re := regexp.MustCompile(pattern) matches := re.FindAllStringSubmatch(raw, -1) out := make([]string, 0, len(matches)) for _, match := range matches { if len(match) < 2 { continue } value := strings.TrimSpace(html.UnescapeString(match[1])) if value != "" { out = append(out, value) } } return out } func normalizeJenkinsBranch(branch string) string { branch = strings.TrimSpace(branch) branch = strings.TrimPrefix(branch, "*/") branch = strings.TrimPrefix(branch, "origin/") if branch == "" || strings.ContainsAny(branch, "*$") { return "" } return branch } func parseJenkinsConfigParameters(raw string) []jenkinsParameterDefinition { re := regexp.MustCompile(`(?s)<([^/][^>\s]*ParameterDefinition[^>]*)>(.*?)]*ParameterDefinition>`) matches := re.FindAllStringSubmatch(raw, -1) out := make([]jenkinsParameterDefinition, 0, len(matches)) for _, match := range matches { if len(match) < 3 { continue } className := strings.TrimSpace(match[1]) body := match[2] name := firstXMLValue(body, `(?s)(.*?)`) if name == "" { continue } def := jenkinsParameterDefinition{ Class: className, Name: name, Type: className, Description: firstXMLValue(body, `(?s)(.*?)`), Choices: parseJenkinsConfigChoices(body), } if v := firstXMLValue(body, `(?s)(.*?)`); v != "" { def.Default = v } else if v := firstXMLValue(body, `(?s).*?(.*?)`); v != "" { def.Default = v } out = append(out, def) } return out } func parseJenkinsConfigChoices(body string) []string { choicesBlock := firstXMLValue(body, `(?s)]*>(.*?)`) if choicesBlock != "" { choices := allXMLValues(choicesBlock, `(?s)(.*?)`) if len(choices) > 0 { return uniqueSortedStrings(choices) } } value := firstXMLValue(body, `(?s)(.*?)`) if value == "" { value = firstXMLValue(body, `(?s)(.*?)`) } if strings.Contains(value, ",") || strings.Contains(value, "\n") { parts := regexp.MustCompile(`[,\n\r]+`).Split(value, -1) return uniqueSortedStrings(parts) } return nil } func mergeJenkinsParameters(primary, fallback []jenkinsParameterDefinition) []jenkinsParameterDefinition { merged := map[string]jenkinsParameterDefinition{} order := []string{} add := func(def jenkinsParameterDefinition) { name := strings.TrimSpace(def.Name) if name == "" { return } existing, ok := merged[name] if !ok { order = append(order, name) merged[name] = def return } if existing.Type == "" { existing.Type = def.Type } if existing.Class == "" { existing.Class = def.Class } if existing.Description == "" { existing.Description = def.Description } if len(existing.Choices) == 0 { existing.Choices = def.Choices } if existing.Default == nil { existing.Default = def.Default } merged[name] = existing } for _, def := range primary { add(def) } for _, def := range fallback { add(def) } out := make([]jenkinsParameterDefinition, 0, len(order)) for _, name := range order { out = append(out, merged[name]) } return out } func sourceFromJenkinsParameters(params []jenkinsParameterDefinition) string { for _, param := range params { name := strings.ToLower(strings.TrimSpace(param.Name)) if name != "git_url" && name != "giturl" && name != "repo_url" && name != "repository" { continue } if param.Default != nil { if v := strings.TrimSpace(fmt.Sprint(param.Default)); strings.Contains(v, ".git") || strings.Contains(v, "://") || strings.Contains(v, "@") { return v } } } return "" } func mergeJenkinsJobs(merged map[string]jenkinsJob, jobs ...jenkinsJob) { for _, job := range jobs { key := strings.TrimRight(job.URL, "/") if key == "" { key = job.Name } existing, ok := merged[key] if !ok { job.Views = normalizeJenkinsJobViews(job.Views) job.ViewName, job.ViewURL = preferredJenkinsJobView(job.Views, job.ViewName, job.ViewURL) merged[key] = job continue } existing.Views = normalizeJenkinsJobViews(append(existing.Views, job.Views...)) existing.ViewName, existing.ViewURL = preferredJenkinsJobView(existing.Views, existing.ViewName, existing.ViewURL) if existing.Color == "" { existing.Color = job.Color } if existing.Description == "" { existing.Description = job.Description } if existing.LastBuild == nil { existing.LastBuild = job.LastBuild } merged[key] = existing } } func appendJenkinsJobView(views []jenkinsJobView, view jenkinsJobView) []jenkinsJobView { if strings.TrimSpace(view.Name) == "" { return views } for _, item := range views { if item.Name == view.Name && item.URL == view.URL { return views } } return append(views, view) } func normalizeJenkinsJobViews(views []jenkinsJobView) []jenkinsJobView { out := make([]jenkinsJobView, 0, len(views)) for _, view := range views { out = appendJenkinsJobView(out, view) } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) }) return out } func preferredJenkinsJobView(views []jenkinsJobView, fallbackName, fallbackURL string) (string, string) { for _, view := range views { if !strings.EqualFold(view.Name, "all") { return view.Name, view.URL } } for _, view := range views { if strings.TrimSpace(view.Name) != "" { return view.Name, view.URL } } return fallbackName, fallbackURL } func encodeJenkinsJobViews(views []jenkinsJobView) string { views = normalizeJenkinsJobViews(views) if len(views) == 0 { return "" } buf, err := json.Marshal(views) if err != nil { return "" } return string(buf) } func countJenkinsViews(jobs []jenkinsJob) int { seen := map[string]bool{} for _, job := range jobs { for _, view := range job.Views { if strings.TrimSpace(view.Name) != "" && !strings.EqualFold(view.Name, "all") { seen[view.Name] = true } } } return len(seen) } func (job jenkinsJob) Parameters() []jenkinsParameterDefinition { out := []jenkinsParameterDefinition{} for _, prop := range job.Properties { for _, def := range prop.ParameterDefinitions { if strings.TrimSpace(def.Name) == "" { continue } if def.Default == nil && def.DefaultParameterValue != nil { def.Default = def.DefaultParameterValue.Value } out = append(out, def) } } return out } func encodeJenkinsParameters(params []jenkinsParameterDefinition) string { if len(params) == 0 { return "" } buf, err := json.Marshal(params) if err != nil { return "" } return string(buf) } func encodeJenkinsSteps(steps []string) string { if len(steps) == 0 { return "" } items := make([]gin.H, 0, len(steps)) for i, step := range steps { items = append(items, gin.H{"type": "shell", "name": fmt.Sprintf("Shell #%d", i+1), "command": step}) } buf, err := json.Marshal(items) if err != nil { return "" } return string(buf) } func preferredJenkinsRefParam(pipeline model.Pipeline, fallback string) string { params := decodeJenkinsParameters(pipeline.JenkinsParams) for _, param := range params { if strings.EqualFold(param.Name, fallback) { return param.Name } } for _, param := range params { if strings.Contains(param.Type, "GitParameterDefinition") || strings.HasPrefix(param.Type, "PT_") { return param.Name } } return normalizeJenkinsRefParam(fallback) } func decodeJenkinsParameters(raw string) []jenkinsParameterDefinition { var params []jenkinsParameterDefinition if strings.TrimSpace(raw) == "" { return params } _ = json.Unmarshal([]byte(raw), ¶ms) return params } func jenkinsStaticRefChoices(pipeline model.Pipeline, refType string) []string { out := []string{} for _, param := range decodeJenkinsParameters(pipeline.JenkinsParams) { if len(param.Choices) > 0 { for _, choice := range param.Choices { if strings.TrimSpace(choice) != "" { out = append(out, choice) } } } if param.Default != nil { if v := strings.TrimSpace(fmt.Sprint(param.Default)); v != "" { out = append(out, v) } } } if refType == "branch" && strings.TrimSpace(pipeline.Branch) != "" { out = append(out, pipeline.Branch) } return uniqueSortedStrings(out) } func uniqueSortedStrings(items []string) []string { seen := map[string]bool{} out := make([]string, 0, len(items)) for _, item := range items { item = strings.TrimSpace(item) if item == "" || seen[item] { continue } seen[item] = true out = append(out, item) } sort.Strings(out) return out } func listGitRefs(ctx context.Context, repoURL, refType string) ([]string, error) { args := []string{"ls-remote", "--refs"} if refType == "tag" { args = append(args, "--tags") } else { args = append(args, "--heads") } args = append(args, repoURL) ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "git", args...) out, err := cmd.Output() if err != nil { return nil, err } prefix := "refs/heads/" if refType == "tag" { prefix = "refs/tags/" } refs := []string{} for _, line := range strings.Split(string(out), "\n") { parts := strings.Fields(line) if len(parts) < 2 || !strings.HasPrefix(parts[1], prefix) { continue } ref := strings.TrimPrefix(parts[1], prefix) ref = strings.TrimSuffix(ref, "^{}") if ref != "" { refs = append(refs, ref) } } return uniqueSortedStrings(refs), nil } func jenkinsLastBuildTime(job jenkinsJob) *time.Time { if job.LastBuild == nil || job.LastBuild.Timestamp <= 0 { return nil } t := time.UnixMilli(job.LastBuild.Timestamp) return &t } func isContainerJenkinsJob(className string) bool { className = strings.ToLower(className) return strings.Contains(className, "folder") || strings.Contains(className, "multibranch") || strings.Contains(className, "organizationfolder") } func isBuildableJenkinsJob(name, jobURL, color, className string, hasLastBuild bool) bool { if strings.TrimSpace(name) == "" || strings.TrimSpace(jobURL) == "" { return false } if isContainerJenkinsJob(className) { return false } className = strings.ToLower(className) return color != "" || hasLastBuild || strings.Contains(className, "workflowjob") || strings.Contains(className, "freestyleproject") || strings.Contains(className, "mavenmoduleset") || strings.Contains(className, "matrixproject") || strings.Contains(className, "job") } func (c *jenkinsClient) trigger(ctx context.Context, pipeline model.Pipeline, params map[string]string) (string, error) { if err := c.ensureCrumb(ctx); err != nil { return "", err } jobURL := strings.TrimRight(pipeline.JenkinsJobURL, "/") if jobURL == "" { jobURL = c.baseURL + "/job/" + url.PathEscape(pipeline.JenkinsJobName) } form := url.Values{} for k, v := range params { if strings.TrimSpace(v) != "" { form.Set(k, v) } } endpoint := jobURL + "/build" if len(form) > 0 { endpoint = jobURL + "/buildWithParameters" } resp, err := c.request(ctx, http.MethodPost, endpoint, form) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusOK { return "", fmt.Errorf("trigger status %d", resp.StatusCode) } queueURL := resp.Header.Get("Location") if queueURL == "" { queueURL = jobURL + "/lastBuild" } return queueURL, nil } func (c *jenkinsClient) waitQueue(ctx context.Context, queueURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) { if strings.Contains(queueURL, "/lastBuild") { return c.getBuild(ctx, queueURL) } apiURL := strings.TrimRight(queueURL, "/") + "/api/json" for { select { case <-ctx.Done(): return jenkinsBuildInfo{}, ctx.Err() case <-time.After(jenkinsPollInterval): } resp, err := c.request(ctx, http.MethodGet, apiURL, nil) if err != nil { return jenkinsBuildInfo{}, err } var item jenkinsQueueItem err = json.NewDecoder(resp.Body).Decode(&item) resp.Body.Close() if err != nil { return jenkinsBuildInfo{}, err } if item.Cancelled { return jenkinsBuildInfo{}, fmt.Errorf("queue cancelled: %s", item.Why) } if item.Executable != nil { return jenkinsBuildInfo{Number: item.Executable.Number, URL: item.Executable.URL, Building: true}, nil } if item.Why != "" { logFn("info", "Jenkins", ">>> 等待队列: "+item.Why) } } } func (c *jenkinsClient) waitBuild(ctx context.Context, buildURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) { var consoleOffset int64 for { build, err := c.getBuild(ctx, buildURL) if err != nil { return build, err } console, nextOffset, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset) if console != "" { logJenkinsConsoleChunk(logFn, console) consoleOffset = nextOffset } if !build.Building && build.Result != "" { console, _, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset) if console != "" { logJenkinsConsoleChunk(logFn, console) } return build, nil } logFn("info", "Jenkins", fmt.Sprintf(">>> Build #%d 仍在执行...", build.Number)) select { case <-ctx.Done(): return build, ctx.Err() case <-time.After(jenkinsPollInterval): } } } func (c *jenkinsClient) getBuild(ctx context.Context, buildURL string) (jenkinsBuildInfo, error) { resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/api/json", nil) if err != nil { return jenkinsBuildInfo{}, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return jenkinsBuildInfo{}, fmt.Errorf("build api status %d", resp.StatusCode) } var info jenkinsBuildInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return info, err } if info.URL == "" { info.URL = strings.TrimRight(buildURL, "/") } return info, nil } func (c *jenkinsClient) consoleText(ctx context.Context, buildURL string) string { resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/consoleText", nil) if err != nil { return "" } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "" } b, _ := io.ReadAll(resp.Body) return string(b) } func (c *jenkinsClient) progressiveConsoleText(ctx context.Context, buildURL string, start int64) (string, int64, bool) { endpoint := fmt.Sprintf("%s/logText/progressiveText?start=%d", strings.TrimRight(buildURL, "/"), start) resp, err := c.request(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", start, false } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", start, false } b, _ := io.ReadAll(resp.Body) next := start if h := resp.Header.Get("X-Text-Size"); h != "" { if n, err := strconv.ParseInt(h, 10, 64); err == nil { next = n } } else { next = start + int64(len(b)) } return string(b), next, strings.EqualFold(resp.Header.Get("X-More-Data"), "true") } func logJenkinsConsoleChunk(logFn func(string, string, string), chunk string) { normalized := strings.ReplaceAll(chunk, "\r\n", "\n") normalized = strings.ReplaceAll(normalized, "\r", "\n") for _, line := range strings.Split(normalized, "\n") { if strings.TrimSpace(line) == "" { continue } logFn("info", "Console", line) } } func pipelineCodeFromJenkinsJob(prefix, name string) string { parts := []string{} if strings.TrimSpace(prefix) != "" { parts = append(parts, strings.TrimSpace(prefix)) } parts = append(parts, strings.TrimSpace(name)) raw := strings.ToLower(strings.Join(parts, "-")) re := regexp.MustCompile(`[^a-z0-9]+`) code := strings.Trim(re.ReplaceAllString(raw, "-"), "-") if code == "" { code = "jenkins-job" } if len(code) > 54 { sum := sha1.Sum([]byte(raw)) code = strings.Trim(code[:54], "-") + "-" + hex.EncodeToString(sum[:])[:8] } return code } func (h *PipelineHandler) uniquePipelineCode(base string) string { code := base for i := 0; i < 100; i++ { var count int64 h.db.Model(&model.Pipeline{}).Where("code = ? OR job_name = ?", code, code).Count(&count) if count == 0 { return code } code = fmt.Sprintf("%s-%d", strings.TrimRight(base, "-"), i+1) if len(code) > 64 { code = code[:64] } } return base + "-jenkins" } func jenkinsColorToStatus(color string) string { color = strings.ToLower(color) switch { case strings.Contains(color, "blue"): return "success" case strings.Contains(color, "red"): return "failed" case strings.Contains(color, "anime"): return "running" default: return "" } }