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:
Logan.Li committed 2026-10-27 09:07:42 +08:00
1 parent 88d8fc282e
commit 581d0a8351
12 files changed
+1415 -2

No files matched your search

+486
View File
@@ -0,0 +1,486 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/llm"
"flowstack/internal/model"
"flowstack/internal/parser"
)
// ============================================================
// 候选池:流水线 + Jenkins Job(60s 内存缓存)
// ============================================================
type Candidate struct {
Name string `json:"name"` // 匹配用名称(jenkins 为压平全路径)
PipelineID uint `json:"pipelineId"`
Type string `json:"type"` // native | jenkins
Source string `json:"source"` // pipeline | jenkins
ServerID uint `json:"serverId"`
}
var (
jobsCacheMu sync.Mutex
jobsCacheData []Candidate // 仅 jenkins 来源
jobsCacheAt time.Time
)
// jenkinsCandidates 从各启用的 Jenkins 服务器拉取 Job 列表(60s 缓存)
func jenkinsCandidates() []Candidate {
jobsCacheMu.Lock()
defer jobsCacheMu.Unlock()
if time.Since(jobsCacheAt) < 60*time.Second && jobsCacheData != nil {
return jobsCacheData
}
var servers []model.JenkinsServer
database.DB.Where("enabled = ?", true).Find(&servers)
var out []Candidate
for _, s := range servers {
jobs, err := clientFromServer(s).ListJobs()
if err != nil {
continue
}
for _, j := range jobs {
if j.Type == "folder" {
continue
}
out = append(out, Candidate{Name: j.Name, Type: "jenkins", Source: "jenkins", ServerID: s.ID})
}
}
jobsCacheData = out
jobsCacheAt = time.Now()
return out
}
// allCandidates 流水线优先(去重),Jenkins Job 补充
func allCandidates() []Candidate {
var pipelines []model.Pipeline
database.DB.Find(&pipelines)
var out []Candidate
seen := map[string]bool{}
for _, p := range pipelines {
out = append(out, Candidate{Name: p.Name, PipelineID: p.ID, Type: p.Type, Source: "pipeline"})
seen[strings.ToLower(p.Name)] = true
if p.JenkinsJobName != "" && p.JenkinsJobName != p.Name {
if !seen[strings.ToLower(p.JenkinsJobName)] {
out = append(out, Candidate{Name: p.JenkinsJobName, PipelineID: p.ID, Type: "jenkins", Source: "pipeline", ServerID: p.JenkinsServerID})
seen[strings.ToLower(p.JenkinsJobName)] = true
}
}
}
for _, j := range jenkinsCandidates() {
if !seen[strings.ToLower(j.Name)] {
out = append(out, j)
seen[strings.ToLower(j.Name)] = true
}
}
return out
}
// matchCandidate 精确 → 忽略大小写 → 包含
func matchCandidate(name string, candidates []Candidate) *Candidate {
lower := strings.ToLower(name)
var contains *Candidate
for i, c := range candidates {
if c.Name == name {
return &candidates[i]
}
if strings.ToLower(c.Name) == lower {
return &candidates[i]
}
if contains == nil && lower != "" && strings.Contains(strings.ToLower(c.Name), lower) {
contains = &candidates[i]
}
}
return contains
}
// ============================================================
// LLM
// ============================================================
func llmClientFromSettings() *llm.Client {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
return nil
}
if !s.LlmEnabled || s.LlmApiKey == "" {
return nil
}
apiKey := s.LlmApiKey
if crypto.IsEncrypted(apiKey) {
if plain, err := crypto.DecryptField(apiKey); err == nil {
apiKey = plain
}
}
return llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, s.LlmMaxTokens)
}
// ============================================================
// 规则解析(无 LLM 的兜底)
// ============================================================
var branchKeywords = []string{"pre", "test", "main", "master", "release", "dev", "develop", "staging", "production", "prod"}
type ruleParseResult struct {
Jobs []string
Branch string
Env string
}
// parseByRules 从自然语言/清单文本中直接匹配候选名 + 分支/环境关键词
func parseByRules(text string, candidates []Candidate) ruleParseResult {
var res ruleParseResult
lowerText := strings.ToLower(text)
// 长名优先匹配,避免短名误命中(如 api 命中 xxx-api)
sorted := make([]Candidate, len(candidates))
copy(sorted, candidates)
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if len(sorted[j].Name) > len(sorted[i].Name) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
for _, c := range sorted {
if c.Name == "" {
continue
}
if strings.Contains(lowerText, strings.ToLower(c.Name)) {
res.Jobs = append(res.Jobs, c.Name)
}
}
// 分支关键词(优先匹配更长的,如 origin/pre 优于 pre)
for _, kw := range []string{"origin/pre", "origin/test", "origin/main"} {
if strings.Contains(lowerText, kw) {
res.Branch = kw
break
}
}
if res.Branch == "" {
for _, kw := range branchKeywords {
if strings.Contains(lowerText, kw) {
res.Branch = kw
break
}
}
}
for _, env := range []string{"production", "prod", "staging", "test", "dev"} {
if strings.Contains(lowerText, env) {
res.Env = env
break
}
}
return res
}
// ============================================================
// POST /api/ai/chat
// ============================================================
type ChatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
func AiChat(c *gin.Context) {
var body struct {
Messages []ChatMsg `json:"messages" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
return
}
// 只取用户侧最后一条作为解析输入
var lastUser string
for i := len(body.Messages) - 1; i >= 0; i-- {
if body.Messages[i].Role == "user" {
lastUser = body.Messages[i].Content
break
}
}
if lastUser == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
return
}
candidates := allCandidates()
names := make([]string, 0, len(candidates))
if len(candidates) > 200 {
candidates = candidates[:200]
}
for _, cd := range candidates {
names = append(names, cd.Name)
}
// 规则结果(兜底 + 校验基准)
ruleRes := parseByRules(lastUser, candidates)
reply := ""
viaAI := false
var action *aiReleaseAction
client := llmClientFromSettings()
if client != nil {
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
%s
请从用户消息中识别:要发布哪些服务(必须来自上面的清单,按原文匹配)、目标分支(如有)、目标环境(如有)。
严格输出 JSON(不要输出其他内容、不要 markdown 代码块):
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
jobs 为空数组时 reply 中说明未识别到服务。`, strings.Join(names, "\n"))
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
if out, err := client.Chat(ctx, system, lastUser); err == nil {
viaAI = true
reply, action = parseLLMJson(out, candidates)
}
}
// 兜底或 LLM 无结果:规则结果
if action == nil || len(action.Jobs) == 0 {
if len(ruleRes.Jobs) == 0 {
if reply == "" {
reply = "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。"
}
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": viaAI})
return
}
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
for _, name := range ruleRes.Jobs {
m := matchCandidate(name, candidates)
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
}
action = &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
if reply == "" {
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch))
}
}
c.JSON(http.StatusOK, gin.H{
"reply": reply, "viaAI": viaAI, "action": action,
})
}
type aiActionJob struct {
Name string `json:"name"`
Matched bool `json:"matched"`
Source string `json:"source"`
}
type aiReleaseAction struct {
Type string `json:"type"`
Jobs []aiActionJob `json:"jobs"`
Branch string `json:"branch,omitempty"`
Env string `json:"env,omitempty"`
}
func sourceOf(m *Candidate) string {
if m == nil {
return "unknown"
}
return m.Source
}
func branchSuffix(branch string) string {
if branch == "" {
return ""
}
return "(分支: " + branch + ")"
}
// parseLLMJson 容错解析 LLM 输出(允许 ```json 包裹),把识别出的服务匹配到候选池
func parseLLMJson(out string, candidates []Candidate) (string, *aiReleaseAction) {
cleaned := strings.TrimSpace(out)
cleaned = strings.TrimPrefix(cleaned, "```json")
cleaned = strings.TrimPrefix(cleaned, "```")
cleaned = strings.TrimSuffix(cleaned, "```")
// 截取首个 { 到最后一个 } 之间的内容
if idx := strings.Index(cleaned, "{"); idx >= 0 {
if last := strings.LastIndex(cleaned, "}"); last > idx {
cleaned = cleaned[idx : last+1]
}
}
var parsed struct {
Reply string `json:"reply"`
Jobs []string `json:"jobs"`
Branch string `json:"branch"`
Env string `json:"env"`
}
if err := json.Unmarshal([]byte(cleaned), &parsed); err != nil {
return "", nil
}
if len(parsed.Jobs) == 0 {
if parsed.Reply != "" {
return parsed.Reply, nil
}
return "", nil
}
action := &aiReleaseAction{Type: "release", Branch: parsed.Branch, Env: parsed.Env}
for _, name := range parsed.Jobs {
m := matchCandidate(name, candidates)
action.Jobs = append(action.Jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
}
reply := parsed.Reply
if reply == "" {
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(action.Jobs), branchSuffix(parsed.Branch))
}
return reply, action
}
// ============================================================
// POST /api/ai/release —— 解析→(自动建)→触发
// ============================================================
func AiRelease(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
Jobs []string `json:"jobs" binding:"required"`
Branch string `json:"branch"`
Env string `json:"env"`
Parallel bool `json:"parallel"`
JenkinsServerID uint `json:"jenkinsServerId"`
AutoCreate *bool `json:"autoCreate"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "jobs 不能为空"})
return
}
autoCreate := body.AutoCreate == nil || *body.AutoCreate
candidates := allCandidates()
type triggered struct {
Job string `json:"job"`
PipelineID uint `json:"pipelineId"`
RunID string `json:"runId"`
Created bool `json:"created"`
}
type failed struct {
Job string `json:"job"`
Reason string `json:"reason"`
}
var okList []triggered
var failList []failed
for _, name := range body.Jobs {
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
if err != nil {
failList = append(failList, failed{Job: name, Reason: err.Error()})
continue
}
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch")
if err != nil {
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
continue
}
okList = append(okList, triggered{Job: name, PipelineID: p.ID, RunID: run.RunID, Created: created})
if !body.Parallel {
time.Sleep(500 * time.Millisecond)
}
}
c.JSON(http.StatusOK, gin.H{"triggered": okList, "failed": failList})
}
}
// resolvePipeline 名称 → 流水线;未导入的 Jenkins Job 自动创建
func resolvePipeline(name string, candidates []Candidate, autoCreate bool, serverID uint) (*model.Pipeline, bool, error) {
var p model.Pipeline
if err := database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&p).Error; err == nil {
return &p, false, nil
}
if !autoCreate {
return nil, false, fmt.Errorf("未找到流水线(未开启自动创建)")
}
// 在 Jenkins 候选中确认存在并定位服务器
m := matchCandidate(name, candidates)
if m == nil || m.Source != "jenkins" {
return nil, false, fmt.Errorf("流水线与 Jenkins Job 中均未找到")
}
sid := m.ServerID
if sid == 0 {
sid = serverID
}
if sid == 0 {
return nil, false, fmt.Errorf("无法确定 Jenkins 服务器")
}
np := model.Pipeline{
Name: m.Name, Description: "AI 助手自动创建",
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
JenkinsJobName: m.Name, JenkinsServerID: sid,
}
if err := database.DB.Create(&np).Error; err != nil {
return nil, false, fmt.Errorf("自动创建流水线失败: %s", err.Error())
}
return &np, true, nil
}
// ============================================================
// POST /api/batch/parse —— 批量发布页粘贴清单解析(纯规则)
// ============================================================
func BatchParse(c *gin.Context) {
var body struct {
Text string `json:"text" binding:"required"`
RefType string `json:"refType"`
FixedRef string `json:"fixedRef"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
return
}
res, err := parser.Parse(body.Text, body.RefType, body.FixedRef)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
candidates := allCandidates()
type parsedJob struct {
parser.Job
Matched bool `json:"matched"`
Source string `json:"source"`
PipelineID uint `json:"pipelineId"`
AutoCreatable bool `json:"autoCreatable"` // Jenkins 中存在可自动创建
}
jobs := make([]parsedJob, 0, len(res.Jobs))
for _, j := range res.Jobs {
m := matchCandidate(j.Name, candidates)
pj := parsedJob{Job: j, Matched: m != nil, Source: sourceOf(m)}
if m != nil {
pj.PipelineID = m.PipelineID
} else {
// 未匹配到流水线,但 Jenkins 里有同名 Job → 可自动创建
jm := matchCandidate(j.Name, filterJenkins(candidates))
if jm != nil {
pj.AutoCreatable = true
pj.Source = "jenkins"
}
}
jobs = append(jobs, pj)
}
c.JSON(http.StatusOK, gin.H{"jobs": jobs, "param": res.ParamName, "refType": res.RefType, "count": len(jobs)})
}
func filterJenkins(candidates []Candidate) []Candidate {
var out []Candidate
for _, c := range candidates {
if c.Source == "jenkins" {
out = append(out, c)
}
}
return out
}
+56 -1
View File
@@ -1,12 +1,15 @@
package handler
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/llm"
"flowstack/internal/model"
)
@@ -83,9 +86,10 @@ func GetSummary(c *gin.Context) {
func GetSettings(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1}
s = model.SystemSetting{ID: 1, LlmEndpoint: "https://api.deepseek.com/v1", LlmModel: "deepseek-chat", LlmMaxTokens: 1200}
database.DB.Create(&s)
}
s.LlmApiKeySet = s.LlmApiKey != ""
c.JSON(http.StatusOK, s)
}
@@ -114,10 +118,61 @@ func UpdateSettings(c *gin.Context) {
if v, ok := body["extra"].(string); ok {
updates["extra"] = v
}
if v, ok := body["llmEnabled"].(bool); ok {
updates["llm_enabled"] = v
}
if v, ok := body["llmEndpoint"].(string); ok {
updates["llm_endpoint"] = v
}
if v, ok := body["llmModel"].(string); ok {
updates["llm_model"] = v
}
if v, ok := body["llmMaxTokens"].(float64); ok {
updates["llm_max_tokens"] = int(v)
}
// API Key 只写不读:非空才更新
if v, ok := body["llmApiKey"].(string); ok && v != "" {
enc, err := crypto.EncryptField(v)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "API Key 加密失败"})
return
}
updates["llm_api_key"] = enc
}
database.DB.Model(&s).Updates(updates)
database.DB.First(&s, 1)
s.LlmApiKeySet = s.LlmApiKey != ""
c.JSON(http.StatusOK, s)
}
// TestLLM POST /api/settings/test-llm —— 用已保存配置做 ping 对话
func TestLLM(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
return
}
if !s.LlmEnabled || s.LlmApiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "AI 未启用或未配置 API Key"})
return
}
apiKey := s.LlmApiKey
if crypto.IsEncrypted(apiKey) {
if plain, err := crypto.DecryptField(apiKey); err == nil {
apiKey = plain
}
}
client := llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, 128)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping")
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": reply})
}
// CreatePipeline POST /api/pipelines
func CreatePipeline(c *gin.Context) {
var input struct {
+113
View File
@@ -0,0 +1,113 @@
// Package llm OpenAI 兼容 Chat 客户端(移植自 opsx-jenkins 生产实现)。
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
Endpoint string
APIKey string
Model string
MaxTokens int
}
func NewClient(endpoint, apiKey, model string, maxTokens int) *Client {
if maxTokens <= 0 {
maxTokens = 1200
}
return &Client{
Endpoint: strings.TrimRight(endpoint, "/"),
APIKey: apiKey,
Model: model,
MaxTokens: maxTokens,
}
}
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat 单轮对话;endpoint/apiKey 为空时返回错误由调用方降级
func (c *Client) Chat(ctx context.Context, system, user string) (string, error) {
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
return "", fmt.Errorf("LLM 未配置")
}
payload := chatRequest{
Model: c.Model,
Messages: []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Temperature: 0.2,
MaxTokens: c.MaxTokens,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var out chatResponse
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("响应解析失败: %s", truncate(string(data), 200))
}
if out.Error != nil && out.Error.Message != "" {
return "", fmt.Errorf("LLM 错误: %s", out.Error.Message)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("空响应")
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+6
View File
@@ -267,6 +267,12 @@ type SystemSetting struct {
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins
LlmEnabled bool `gorm:"default:false" json:"llmEnabled"`
LlmEndpoint string `gorm:"default:https://api.deepseek.com/v1" json:"llmEndpoint"`
LlmModel string `gorm:"default:deepseek-chat" json:"llmModel"`
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
Extra string `gorm:"type:text" json:"extra"`
UpdatedAt time.Time `json:"updatedAt"`
}
+168
View File
@@ -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
}
+5
View File
@@ -54,6 +54,11 @@ func main() {
api.GET("/summary", handler.GetSummary)
api.GET("/settings", handler.GetSettings)
api.PUT("/settings", handler.UpdateSettings)
api.POST("/settings/test-llm", handler.TestLLM)
api.POST("/ai/chat", handler.AiChat)
api.POST("/ai/release", handler.AiRelease(hub))
api.POST("/batch/parse", handler.BatchParse)
api.GET("/pipelines", handler.ListPipelines)
api.POST("/pipelines", handler.CreatePipeline)