package common import ( "context" "crypto/tls" "fmt" "io" "net/http" "strings" "sync" "time" jira "github.com/andygrunwald/go-jira" "github.com/gogf/gf/frame/g" ) const ( // JiraAuthTypeBasic 表示使用用户名+密码进行 Basic Auth 认证。 JiraAuthTypeBasic = "basic" // JiraAuthTypePAT 表示使用 Personal Access Token 认证(Jira Data Center 常用)。 JiraAuthTypePAT = "pat" // JiraAuthTypeBearer 表示使用 Bearer Token 认证。 JiraAuthTypeBearer = "bearer" ) // JiraClientConfig Jira 客户端配置。 // BaseURL 示例: https://jira.example.com // 注意: authType=pat 时使用 Token 字段;authType=basic 时使用 Username/Password 字段。 type JiraClientConfig struct { BaseURL string AuthType string Username string Password string Token string Timeout time.Duration InsecureSkipVerify bool } // JiraClient 对 go-jira 的二次封装,统一对接 Jira Data Center API。 type JiraClient struct { client *jira.Client } var ( // jiraClientMap 按配置前缀缓存 Jira 客户端实例,实现“单例 + 可刷新”模式。 // 例如前缀为 jira、thirdparty.jira 时会分别缓存。 jiraClientMap = make(map[string]*JiraClient) jiraClientMu sync.RWMutex ) // GetJiraClient 获取 Jira 客户端单例(按配置前缀隔离)。 // 若实例不存在则按当前配置初始化并缓存,后续调用直接复用。 func GetJiraClient(prefix ...string) (*JiraClient, error) { cfgPrefix := normalizeJiraConfigPrefix(prefix...) jiraClientMu.RLock() if c, ok := jiraClientMap[cfgPrefix]; ok && c != nil { jiraClientMu.RUnlock() return c, nil } jiraClientMu.RUnlock() jiraClientMu.Lock() defer jiraClientMu.Unlock() // 双重检查,避免并发重复初始化。 if c, ok := jiraClientMap[cfgPrefix]; ok && c != nil { return c, nil } c, err := NewJiraClientFromConfig(cfgPrefix) if err != nil { return nil, err } jiraClientMap[cfgPrefix] = c return c, nil } // ReloadJiraClient 强制按最新配置重建并覆盖 Jira 客户端实例。 // 适合配置热更新、Token 轮换等场景。 func ReloadJiraClient(prefix ...string) (*JiraClient, error) { cfgPrefix := normalizeJiraConfigPrefix(prefix...) c, err := NewJiraClientFromConfig(cfgPrefix) if err != nil { return nil, err } jiraClientMu.Lock() jiraClientMap[cfgPrefix] = c jiraClientMu.Unlock() return c, nil } // ResetJiraClient 仅用于测试或特殊运维场景,清理指定前缀或全部缓存实例。 func ResetJiraClient(prefix ...string) { jiraClientMu.Lock() defer jiraClientMu.Unlock() if len(prefix) == 0 { jiraClientMap = make(map[string]*JiraClient) return } delete(jiraClientMap, normalizeJiraConfigPrefix(prefix...)) } // NewJiraClientFromConfig 从 GoFrame 配置中心读取配置并创建 Jira 客户端。 // 默认读取 config.toml 中的 `jira` 配置段。 // 可选参数 prefix 用于自定义前缀,例如传入 "thirdparty.jira" 时读取 thirdparty.jira.*。 func NewJiraClientFromConfig(prefix ...string) (*JiraClient, error) { cfgPrefix := normalizeJiraConfigPrefix(prefix...) cfg := JiraClientConfig{ BaseURL: g.Cfg().GetString(cfgPrefix + ".base-url"), AuthType: g.Cfg().GetString(cfgPrefix+".auth-type", JiraAuthTypePAT), Username: g.Cfg().GetString(cfgPrefix + ".username"), Password: g.Cfg().GetString(cfgPrefix + ".password"), Token: g.Cfg().GetString(cfgPrefix + ".token"), InsecureSkipVerify: g.Cfg().GetBool(cfgPrefix+".insecure-skip-verify", false), Timeout: g.Cfg().GetDuration(cfgPrefix+".timeout", 30*time.Second), } return NewJiraClient(cfg) } // normalizeJiraConfigPrefix 统一处理配置前缀,空值默认 jira。 func normalizeJiraConfigPrefix(prefix ...string) string { cfgPrefix := "jira" if len(prefix) > 0 && strings.TrimSpace(prefix[0]) != "" { cfgPrefix = strings.TrimSpace(prefix[0]) } return cfgPrefix } // NewJiraClient 创建 Jira 客户端。 // 默认认证方式为 PAT;默认超时为 30 秒。 func NewJiraClient(cfg JiraClientConfig) (*JiraClient, error) { if strings.TrimSpace(cfg.BaseURL) == "" { return nil, fmt.Errorf("jira base url 不能为空") } timeout := cfg.Timeout if timeout <= 0 { timeout = 30 * time.Second } transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}, } authType := strings.ToLower(strings.TrimSpace(cfg.AuthType)) if authType == "" { authType = JiraAuthTypePAT } var httpClient *http.Client switch authType { case JiraAuthTypeBasic: if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { return nil, fmt.Errorf("basic 认证需要 Username 和 Password") } authTransport := &jira.BasicAuthTransport{ Username: cfg.Username, Password: cfg.Password, Transport: transport, } httpClient = authTransport.Client() case JiraAuthTypePAT: if strings.TrimSpace(cfg.Token) == "" { return nil, fmt.Errorf("pat 认证需要 Token") } authTransport := &jira.PATAuthTransport{ Token: cfg.Token, Transport: transport, } httpClient = authTransport.Client() case JiraAuthTypeBearer: if strings.TrimSpace(cfg.Token) == "" { return nil, fmt.Errorf("bearer 认证需要 Token") } authTransport := &jira.BearerAuthTransport{ Token: cfg.Token, Transport: transport, } httpClient = authTransport.Client() default: return nil, fmt.Errorf("不支持的 jira 认证类型: %s", cfg.AuthType) } httpClient.Timeout = timeout jc, err := jira.NewClient(httpClient, cfg.BaseURL) if err != nil { return nil, fmt.Errorf("创建 jira 客户端失败: %w", err) } return &JiraClient{client: jc}, nil } // RawClient 返回底层 go-jira 客户端。 // 适合调用方需要直接使用 SDK 高级能力时使用。 func (j *JiraClient) RawClient() *jira.Client { return j.client } // CreateIssue 创建 Issue。 func (j *JiraClient) CreateIssue(ctx context.Context, issue *jira.Issue) (*jira.Issue, error) { if j == nil || j.client == nil { return nil, fmt.Errorf("jira 客户端未初始化") } if issue == nil { return nil, fmt.Errorf("issue 不能为空") } created, _, err := j.client.Issue.CreateWithContext(ctx, issue) if err != nil { return nil, fmt.Errorf("创建 issue 失败: %w", err) } return created, nil } // GetIssue 按 issue key 获取 Issue。 // fields 为空时返回 Jira 默认字段集合。 func (j *JiraClient) GetIssue(ctx context.Context, issueKey string, fields ...string) (*jira.Issue, error) { if j == nil || j.client == nil { return nil, fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(issueKey) == "" { return nil, fmt.Errorf("issueKey 不能为空") } opt := &jira.GetQueryOptions{} if len(fields) > 0 { opt.Fields = strings.Join(fields, ",") } issue, _, err := j.client.Issue.GetWithContext(ctx, issueKey, opt) if err != nil { return nil, fmt.Errorf("获取 issue 失败: %w", err) } return issue, nil } // SearchIssues 使用 JQL 搜索 Issue。 // 返回值 total 为 Jira 检索结果总数(非当前页数量)。 func (j *JiraClient) SearchIssues(ctx context.Context, jql string, startAt, maxResults int, fields ...string) (issues []jira.Issue, total int, err error) { if j == nil || j.client == nil { return nil, 0, fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(jql) == "" { return nil, 0, fmt.Errorf("jql 不能为空") } opt := &jira.SearchOptions{StartAt: startAt, MaxResults: maxResults} if len(fields) > 0 { opt.Fields = fields } issueList, resp, err := j.client.Issue.SearchWithContext(ctx, jql, opt) if err != nil { return nil, 0, fmt.Errorf("搜索 issue 失败: %w", err) } if resp != nil { total = resp.Total } return issueList, total, nil } // AddComment 给指定 Issue 添加评论。 func (j *JiraClient) AddComment(ctx context.Context, issueKey, commentBody string) (*jira.Comment, error) { if j == nil || j.client == nil { return nil, fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(issueKey) == "" { return nil, fmt.Errorf("issueKey 不能为空") } if strings.TrimSpace(commentBody) == "" { return nil, fmt.Errorf("commentBody 不能为空") } comment := &jira.Comment{Body: commentBody} created, _, err := j.client.Issue.AddCommentWithContext(ctx, issueKey, comment) if err != nil { return nil, fmt.Errorf("添加评论失败: %w", err) } return created, nil } // GetTransitions 获取 Issue 可执行流转列表。 func (j *JiraClient) GetTransitions(ctx context.Context, issueKey string) ([]jira.Transition, error) { if j == nil || j.client == nil { return nil, fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(issueKey) == "" { return nil, fmt.Errorf("issueKey 不能为空") } transitions, _, err := j.client.Issue.GetTransitionsWithContext(ctx, issueKey) if err != nil { return nil, fmt.Errorf("获取流转列表失败: %w", err) } return transitions, nil } // TransitionIssue 执行 Issue 流转。 // transitionID 可通过 GetTransitions 获取;comment 非空时会在流转时写入评论。 func (j *JiraClient) TransitionIssue(ctx context.Context, issueKey, transitionID, comment string) error { if j == nil || j.client == nil { return fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(issueKey) == "" { return fmt.Errorf("issueKey 不能为空") } if strings.TrimSpace(transitionID) == "" { return fmt.Errorf("transitionID 不能为空") } payload := jira.CreateTransitionPayload{ Transition: jira.TransitionPayload{ID: transitionID}, } if strings.TrimSpace(comment) != "" { payload.Update = jira.TransitionPayloadUpdate{ Comment: []jira.TransitionPayloadComment{ { Add: jira.TransitionPayloadCommentBody{Body: comment}, }, }, } } _, err := j.client.Issue.DoTransitionWithPayloadWithContext(ctx, issueKey, payload) if err != nil { return fmt.Errorf("执行流转失败: %w", err) } return nil } // UploadAttachment 上传附件到指定 Issue。 func (j *JiraClient) UploadAttachment(ctx context.Context, issueKey, fileName string, reader io.Reader) ([]jira.Attachment, error) { if j == nil || j.client == nil { return nil, fmt.Errorf("jira 客户端未初始化") } if strings.TrimSpace(issueKey) == "" { return nil, fmt.Errorf("issueKey 不能为空") } if strings.TrimSpace(fileName) == "" { return nil, fmt.Errorf("fileName 不能为空") } if reader == nil { return nil, fmt.Errorf("reader 不能为空") } attachments, _, err := j.client.Issue.PostAttachmentWithContext(ctx, issueKey, reader, fileName) if err != nil { return nil, fmt.Errorf("上传附件失败: %w", err) } if attachments == nil { return []jira.Attachment{}, nil } return *attachments, nil } // BuildCreateIssueRequest 构建创建 Issue 的请求体。 // issueTypeName 示例: Task / Bug / Story。 func BuildCreateIssueRequest(projectKey, issueTypeName, summary, description string) *jira.Issue { return &jira.Issue{ Fields: &jira.IssueFields{ Project: jira.Project{Key: projectKey}, Type: jira.IssueType{ Name: issueTypeName, }, Summary: summary, Description: description, }, } }