参考 opsx-jenkins 发布清单: - 批量发布页新增「粘贴清单」模式:多行 Job 名粘贴(#注释/内联对/ 名称版本交替)、TAG/分支切换、分支快捷 chips、700ms 防抖解析预览、 串行/并发发布;未导入的 Jenkins Job 自动创建流水线后触发 - 解析器移植 opsx-jenkins parser(参数头识别/成对校验/重名检测) 全局 AI 智能助手(右下角悬浮): - 自然语言「发布一下 cex-agent-server 等 5 个」→ 解析服务+分支+环境 → 发布计划卡片(匹配状态着色)→ 确认 → 真实触发 → runId 回显 + 跳转发布记录 - LLM 接入:OpenAI 兼容接口(DeepSeek/GLM/Qwen),系统设置页配置 API Key(加密存储)+ 测试连接;未配置时自动降级本地规则模式, 服务名/分支关键词匹配,功能不中断 - 候选池 = 流水线 + Jenkins Job(60s 缓存);精确/忽略大小写/包含 三级匹配 新端点:POST /api/ai/chat、/api/ai/release、/api/batch/parse、 POST /api/settings/test-llm;SystemSetting 扩展 LLM 字段
169 lines
4.5 KiB
Go
169 lines
4.5 KiB
Go
// 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
|
|
}
|