feat: Jenkins Job 批量发布 + 全局 AI 智能助手 (v0.3.0)
参考 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 字段
This commit is contained in:
1 parent
88d8fc282e
commit
581d0a8351
12 files changed
+1415
-2
No files matched your search
@@ -0,0 +1,168 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user