// Package jenkins Jenkins REST 客户端(移植自 opsx-jenkins 生产实现): // crumb → BasicAuth → buildWithParameters → Location 队列 URL → 队列/构建轮询 → progressiveText 增量控制台。 package jenkins import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) type Client struct { BaseURL string Username string Token string HTTP *http.Client } func NewClient(baseURL, username, token string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), Username: username, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}, } } // JobInfo Job 概要(folder 已压平为 path/name 形式) type JobInfo struct { Name string `json:"name"` // 压平路径 folder/job URL string `json:"url"` Type string `json:"type"` // folder | freestyle | pipeline | multibranch Status string `json:"status"` // success | failed | running | unknown } func (c *Client) do(method, path string, form url.Values) (*http.Response, error) { var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequest(method, c.BaseURL+path, body) if err != nil { return nil, err } req.SetBasicAuth(c.Username, c.Token) if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } // CSRF crumb(未启用时 /crumbIssuer 返回 404,跳过) if crumb, err := c.crumb(); err == nil { req.Header.Set(crumb[0], crumb[1]) } return c.HTTP.Do(req) } func (c *Client) crumb() ([2]string, error) { req, _ := http.NewRequest("GET", c.BaseURL+"/crumbIssuer/api/json", nil) req.SetBasicAuth(c.Username, c.Token) resp, err := c.HTTP.Do(req) if err != nil { return [2]string{}, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return [2]string{}, errors.New("crumb not enabled") } var out struct { CrumbRequestField string `json:"crumbRequestField"` Crumb string `json:"crumb"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Crumb == "" { return [2]string{}, errors.New("no crumb") } return [2]string{out.CrumbRequestField, out.Crumb}, nil } // Test 连通测试:访问 /api/json func (c *Client) Test() error { resp, err := c.do("GET", "/api/json", nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { return errors.New("认证失败:用户名或 Token 错误") } if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } return nil } type rawJob struct { Name string `json:"name"` URL string `json:"url"` Color string `json:"color"` Jobs []rawJob `json:"jobs"` } // colorToStatus Jenkins ball color → 状态 func colorToStatus(color string) string { switch { case color == "": return "unknown" case strings.HasSuffix(color, "anime"): return "running" case strings.HasPrefix(color, "blue"): return "success" case strings.HasPrefix(color, "red"): return "failed" case strings.HasPrefix(color, "notbuilt"), strings.HasPrefix(color, "disabled"), strings.HasPrefix(color, "aborted"): return "unknown" default: return "unknown" } } // ListJobs 递归 3 层拉取 Job 树并压平 folder func (c *Client) ListJobs() ([]JobInfo, error) { resp, err := c.do("GET", "/api/json?tree=jobs[name,url,color,jobs[name,url,color,jobs[name,url,color,jobs[name,url,color]]]]", nil) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } var root struct { Jobs []rawJob `json:"jobs"` } if err := json.NewDecoder(resp.Body).Decode(&root); err != nil { return nil, err } var flat []JobInfo var walk func(jobs []rawJob, prefix string) walk = func(jobs []rawJob, prefix string) { for _, j := range jobs { full := j.Name if prefix != "" { full = prefix + "/" + j.Name } if len(j.Jobs) > 0 { flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "folder", Status: "unknown"}) walk(j.Jobs, full) } else { flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "pipeline", Status: colorToStatus(j.Color)}) } } } walk(root.Jobs, "") return flat, nil } // jobPathToURL folder/job → /job/folder/job/job/name func jobPathToURL(jobPath string) string { segs := strings.Split(jobPath, "/") var b strings.Builder for _, s := range segs { b.WriteString("/job/") b.WriteString(url.PathEscape(s)) } return b.String() } // Trigger 触发构建,返回队列 URL(Location 头;拒绝回退 lastBuild 防串号) func (c *Client) Trigger(jobPath string, params map[string]string) (string, error) { path := jobPathToURL(jobPath) var form url.Values if len(params) > 0 { form = url.Values{} for k, v := range params { form.Set(k, v) } path += "/buildWithParameters" } else { path += "/build" } resp, err := c.do("POST", path, form) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return "", fmt.Errorf("触发失败 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } queueURL := resp.Header.Get("Location") if queueURL == "" { return "", errors.New("未返回队列地址(Location),请检查 Jenkins 版本与权限") } return queueURL, nil } // WaitQueue 等待队列分配为具体构建,返回构建号与构建 URL func (c *Client) WaitQueue(queueURL string, timeout time.Duration, emit func(string)) (int, string, error) { deadline := time.Now().Add(timeout) pollClient := &http.Client{Timeout: 15 * time.Second} for time.Now().Before(deadline) { req, _ := http.NewRequest("GET", queueURL+"api/json", nil) req.SetBasicAuth(c.Username, c.Token) resp, err := pollClient.Do(req) if err == nil { var q struct { Cancelled bool `json:"cancelled"` Executable *struct { Number int `json:"number"` URL string `json:"url"` } `json:"executable"` Why string `json:"why"` } if json.NewDecoder(resp.Body).Decode(&q) == nil { resp.Body.Close() if q.Cancelled { return 0, "", errors.New("构建已被 Jenkins 取消(排队被拒绝)") } if q.Executable != nil { return q.Executable.Number, strings.TrimRight(q.Executable.URL, "/"), nil } if q.Why != "" { emit("排队中: " + q.Why) } } else { resp.Body.Close() } } time.Sleep(2 * time.Second) } return 0, "", errors.New("等待队列超时") } // StreamConsole 增量拉取控制台日志(X-Text-Size/X-More-Data 推进),直到构建结束 func (c *Client) StreamConsole(buildURL string, emit func(string)) { offset := 0 pollClient := &http.Client{Timeout: 15 * time.Second} for { req, _ := http.NewRequest("GET", fmt.Sprintf("%s/logText/progressiveText?start=%d", buildURL, offset), nil) req.SetBasicAuth(c.Username, c.Token) resp, err := pollClient.Do(req) if err != nil { emit("日志拉取失败: " + err.Error()) return } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if len(body) > 0 { for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") { if line != "" { emit(line) } } } if newSize := parseInt(resp.Header.Get("X-Text-Size")); newSize > offset { offset = newSize } if resp.Header.Get("X-More-Data") != "true" { return } time.Sleep(2 * time.Second) } } // WaitResult 轮询构建结果:SUCCESS/FAILURE/ABORTED/null(进行中) func (c *Client) WaitResult(buildURL string, timeout time.Duration) (string, error) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { req, _ := http.NewRequest("GET", buildURL+"/api/json", nil) req.SetBasicAuth(c.Username, c.Token) resp, err := c.HTTP.Do(req) if err == nil { var b struct { Building bool `json:"building"` Result *string `json:"result"` } if json.NewDecoder(resp.Body).Decode(&b) == nil { resp.Body.Close() if !b.Building && b.Result != nil { return *b.Result, nil } } else { resp.Body.Close() } } time.Sleep(3 * time.Second) } return "", errors.New("等待构建结果超时") } func parseInt(s string) int { n := 0 for _, ch := range s { if ch < '0' || ch > '9' { return n } n = n*10 + int(ch-'0') } return n } // MapResult Jenkins 结果 → FlowStack 运行状态 func MapResult(r string) string { switch r { case "SUCCESS": return "success" case "ABORTED": return "cancelled" default: return "failed" } }