| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- 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,
- },
- }
- }
|