Files
FlowStack/backend/internal/parser/parser.go
T
Logan.Li 581d0a8351 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 字段
2026-10-27 09:07:42 +08:00

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
}