// Package parser 发布清单文本解析(规则移植自 opsx-jenkins parser.go)。 package parser import ( "fmt" "strings" ) type Job struct { Name string `json:"name"` Version string `json:"version"` } type Result struct { Jobs []Job `json:"jobs"` ParamName string `json:"param"` RefType string `json:"refType"` // tag | branch } var skipHeaders = map[string]bool{ "jenkins服务": true, "jenkins": true, "服务": true, "发布": true, "发布清单": true, "jobs": true, "job": true, "服务列表": true, } // Parse 解析多行发布文本。 // refType: "branch" 每行一个应用名(版本由 fixedRef 统一覆盖); // "tag" 支持:参数头行、"name version" 内联对(=/:/tab/空格分隔)、名称/版本交替行。 func Parse(text, refType, fixedRef string) (*Result, error) { if refType != "branch" { refType = "tag" } lines := splitNonEmptyLines(text) if len(lines) == 0 { return nil, fmt.Errorf("发布文本为空") } res := &Result{RefType: refType} if refType == "branch" { if fixedRef == "" { fixedRef = "test" } for _, l := range lines { name := l if fields := strings.Fields(l); len(fields) >= 2 { name = fields[0] } res.Jobs = append(res.Jobs, Job{Name: name, Version: fixedRef}) } if err := validate(res.Jobs); err != nil { return nil, err } return res, nil } // tag 模式 var tokens []string for _, l := range lines { norm := strings.ToLower(strings.ReplaceAll(strings.TrimRight(l, ":"), " ", "")) if skipHeaders[norm] { continue } if res.ParamName == "" && isParamHeader(l) { res.ParamName = l continue } if name, ver, ok := splitInlinePair(l); ok { res.Jobs = append(res.Jobs, Job{Name: name, Version: ver}) continue } tokens = append(tokens, l) } if len(tokens)%2 != 0 { last := tokens[len(tokens)-1] return nil, fmt.Errorf("发布文本无法成对解析,剩余孤立行: %s", last) } for i := 0; i < len(tokens); i += 2 { res.Jobs = append(res.Jobs, Job{Name: tokens[i], Version: tokens[i+1]}) } if res.ParamName == "" { res.ParamName = "TAG" } if err := validate(res.Jobs); err != nil { return nil, err } return res, nil } func splitNonEmptyLines(text string) []string { text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n") var out []string for _, l := range strings.Split(text, "\n") { l = strings.TrimSpace(l) if l == "" || strings.HasPrefix(l, "#") { continue } out = append(out, l) } return out } // isParamHeader 识别参数头行:TAG/BRANCH/VERSION/SELECT 等显式词,或全大写+下划线的 Jenkins 参数名 func isParamHeader(line string) bool { explicit := map[string]bool{ "tag": true, "branch": true, "version": true, "select": true, "git_tag": true, "image_tag": true, "git_branch": true, "ref": true, } norm := strings.ToLower(strings.TrimSpace(strings.TrimRight(line, ":"))) if explicit[norm] { return true } if len(line) <= 24 && len(line) >= 2 && !strings.ContainsAny(line, "/-. ") { for _, r := range line { if r != '_' && (r < 'A' || r > 'Z') { return false } } return true } return false } // splitInlinePair "name ver" / "name=ver" / "name:ver" / tab 分隔 func splitInlinePair(line string) (string, string, bool) { for _, sep := range []string{"=", ":"} { if idx := strings.Index(line, sep); idx > 0 { name := strings.TrimSpace(line[:idx]) ver := strings.TrimSpace(line[idx+len(sep):]) if name != "" && ver != "" && looksLikeName(name) && looksLikeVersion(ver) { return name, ver, true } } } fields := strings.FieldsFunc(line, func(r rune) bool { return r == ' ' || r == '\t' }) if len(fields) == 2 && looksLikeName(fields[0]) && looksLikeVersion(fields[1]) { return fields[0], fields[1], true } return "", "", false } func looksLikeName(s string) bool { return len(s) >= 2 && !looksLikeVersion(s) } // looksLikeVersion v 前缀含数字,或含数字且连字符少于 2 个 func looksLikeVersion(s string) bool { rest := strings.TrimPrefix(strings.ToLower(s), "v") if strings.ContainsAny(rest, "0123456789") && strings.Count(rest, "-") < 2 { return true } return false } func validate(jobs []Job) error { if len(jobs) == 0 { return fmt.Errorf("未解析到任何应用") } seen := map[string]int{} for i, j := range jobs { if j.Name == "" || j.Version == "" { return fmt.Errorf("第 %d 项名称或版本为空", i+1) } if prev, ok := seen[j.Name]; ok { return fmt.Errorf("应用 %s 重复出现(第 %d 与第 %d 项)", j.Name, prev+1, i+1) } seen[j.Name] = i } return nil }