jira_client.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. package common
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "sync"
  10. "time"
  11. jira "github.com/andygrunwald/go-jira"
  12. "github.com/gogf/gf/frame/g"
  13. )
  14. const (
  15. // JiraAuthTypeBasic 表示使用用户名+密码进行 Basic Auth 认证。
  16. JiraAuthTypeBasic = "basic"
  17. // JiraAuthTypePAT 表示使用 Personal Access Token 认证(Jira Data Center 常用)。
  18. JiraAuthTypePAT = "pat"
  19. // JiraAuthTypeBearer 表示使用 Bearer Token 认证。
  20. JiraAuthTypeBearer = "bearer"
  21. )
  22. // JiraClientConfig Jira 客户端配置。
  23. // BaseURL 示例: https://jira.example.com
  24. // 注意: authType=pat 时使用 Token 字段;authType=basic 时使用 Username/Password 字段。
  25. type JiraClientConfig struct {
  26. BaseURL string
  27. AuthType string
  28. Username string
  29. Password string
  30. Token string
  31. Timeout time.Duration
  32. InsecureSkipVerify bool
  33. }
  34. // JiraClient 对 go-jira 的二次封装,统一对接 Jira Data Center API。
  35. type JiraClient struct {
  36. client *jira.Client
  37. }
  38. var (
  39. // jiraClientMap 按配置前缀缓存 Jira 客户端实例,实现“单例 + 可刷新”模式。
  40. // 例如前缀为 jira、thirdparty.jira 时会分别缓存。
  41. jiraClientMap = make(map[string]*JiraClient)
  42. jiraClientMu sync.RWMutex
  43. )
  44. // GetJiraClient 获取 Jira 客户端单例(按配置前缀隔离)。
  45. // 若实例不存在则按当前配置初始化并缓存,后续调用直接复用。
  46. func GetJiraClient(prefix ...string) (*JiraClient, error) {
  47. cfgPrefix := normalizeJiraConfigPrefix(prefix...)
  48. jiraClientMu.RLock()
  49. if c, ok := jiraClientMap[cfgPrefix]; ok && c != nil {
  50. jiraClientMu.RUnlock()
  51. return c, nil
  52. }
  53. jiraClientMu.RUnlock()
  54. jiraClientMu.Lock()
  55. defer jiraClientMu.Unlock()
  56. // 双重检查,避免并发重复初始化。
  57. if c, ok := jiraClientMap[cfgPrefix]; ok && c != nil {
  58. return c, nil
  59. }
  60. c, err := NewJiraClientFromConfig(cfgPrefix)
  61. if err != nil {
  62. return nil, err
  63. }
  64. jiraClientMap[cfgPrefix] = c
  65. return c, nil
  66. }
  67. // ReloadJiraClient 强制按最新配置重建并覆盖 Jira 客户端实例。
  68. // 适合配置热更新、Token 轮换等场景。
  69. func ReloadJiraClient(prefix ...string) (*JiraClient, error) {
  70. cfgPrefix := normalizeJiraConfigPrefix(prefix...)
  71. c, err := NewJiraClientFromConfig(cfgPrefix)
  72. if err != nil {
  73. return nil, err
  74. }
  75. jiraClientMu.Lock()
  76. jiraClientMap[cfgPrefix] = c
  77. jiraClientMu.Unlock()
  78. return c, nil
  79. }
  80. // ResetJiraClient 仅用于测试或特殊运维场景,清理指定前缀或全部缓存实例。
  81. func ResetJiraClient(prefix ...string) {
  82. jiraClientMu.Lock()
  83. defer jiraClientMu.Unlock()
  84. if len(prefix) == 0 {
  85. jiraClientMap = make(map[string]*JiraClient)
  86. return
  87. }
  88. delete(jiraClientMap, normalizeJiraConfigPrefix(prefix...))
  89. }
  90. // NewJiraClientFromConfig 从 GoFrame 配置中心读取配置并创建 Jira 客户端。
  91. // 默认读取 config.toml 中的 `jira` 配置段。
  92. // 可选参数 prefix 用于自定义前缀,例如传入 "thirdparty.jira" 时读取 thirdparty.jira.*。
  93. func NewJiraClientFromConfig(prefix ...string) (*JiraClient, error) {
  94. cfgPrefix := normalizeJiraConfigPrefix(prefix...)
  95. cfg := JiraClientConfig{
  96. BaseURL: g.Cfg().GetString(cfgPrefix + ".base-url"),
  97. AuthType: g.Cfg().GetString(cfgPrefix+".auth-type", JiraAuthTypePAT),
  98. Username: g.Cfg().GetString(cfgPrefix + ".username"),
  99. Password: g.Cfg().GetString(cfgPrefix + ".password"),
  100. Token: g.Cfg().GetString(cfgPrefix + ".token"),
  101. InsecureSkipVerify: g.Cfg().GetBool(cfgPrefix+".insecure-skip-verify", false),
  102. Timeout: g.Cfg().GetDuration(cfgPrefix+".timeout", 30*time.Second),
  103. }
  104. return NewJiraClient(cfg)
  105. }
  106. // normalizeJiraConfigPrefix 统一处理配置前缀,空值默认 jira。
  107. func normalizeJiraConfigPrefix(prefix ...string) string {
  108. cfgPrefix := "jira"
  109. if len(prefix) > 0 && strings.TrimSpace(prefix[0]) != "" {
  110. cfgPrefix = strings.TrimSpace(prefix[0])
  111. }
  112. return cfgPrefix
  113. }
  114. // NewJiraClient 创建 Jira 客户端。
  115. // 默认认证方式为 PAT;默认超时为 30 秒。
  116. func NewJiraClient(cfg JiraClientConfig) (*JiraClient, error) {
  117. if strings.TrimSpace(cfg.BaseURL) == "" {
  118. return nil, fmt.Errorf("jira base url 不能为空")
  119. }
  120. timeout := cfg.Timeout
  121. if timeout <= 0 {
  122. timeout = 30 * time.Second
  123. }
  124. transport := &http.Transport{
  125. TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify},
  126. }
  127. authType := strings.ToLower(strings.TrimSpace(cfg.AuthType))
  128. if authType == "" {
  129. authType = JiraAuthTypePAT
  130. }
  131. var httpClient *http.Client
  132. switch authType {
  133. case JiraAuthTypeBasic:
  134. if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
  135. return nil, fmt.Errorf("basic 认证需要 Username 和 Password")
  136. }
  137. authTransport := &jira.BasicAuthTransport{
  138. Username: cfg.Username,
  139. Password: cfg.Password,
  140. Transport: transport,
  141. }
  142. httpClient = authTransport.Client()
  143. case JiraAuthTypePAT:
  144. if strings.TrimSpace(cfg.Token) == "" {
  145. return nil, fmt.Errorf("pat 认证需要 Token")
  146. }
  147. authTransport := &jira.PATAuthTransport{
  148. Token: cfg.Token,
  149. Transport: transport,
  150. }
  151. httpClient = authTransport.Client()
  152. case JiraAuthTypeBearer:
  153. if strings.TrimSpace(cfg.Token) == "" {
  154. return nil, fmt.Errorf("bearer 认证需要 Token")
  155. }
  156. authTransport := &jira.BearerAuthTransport{
  157. Token: cfg.Token,
  158. Transport: transport,
  159. }
  160. httpClient = authTransport.Client()
  161. default:
  162. return nil, fmt.Errorf("不支持的 jira 认证类型: %s", cfg.AuthType)
  163. }
  164. httpClient.Timeout = timeout
  165. jc, err := jira.NewClient(httpClient, cfg.BaseURL)
  166. if err != nil {
  167. return nil, fmt.Errorf("创建 jira 客户端失败: %w", err)
  168. }
  169. return &JiraClient{client: jc}, nil
  170. }
  171. // RawClient 返回底层 go-jira 客户端。
  172. // 适合调用方需要直接使用 SDK 高级能力时使用。
  173. func (j *JiraClient) RawClient() *jira.Client {
  174. return j.client
  175. }
  176. // CreateIssue 创建 Issue。
  177. func (j *JiraClient) CreateIssue(ctx context.Context, issue *jira.Issue) (*jira.Issue, error) {
  178. if j == nil || j.client == nil {
  179. return nil, fmt.Errorf("jira 客户端未初始化")
  180. }
  181. if issue == nil {
  182. return nil, fmt.Errorf("issue 不能为空")
  183. }
  184. created, _, err := j.client.Issue.CreateWithContext(ctx, issue)
  185. if err != nil {
  186. return nil, fmt.Errorf("创建 issue 失败: %w", err)
  187. }
  188. return created, nil
  189. }
  190. // GetIssue 按 issue key 获取 Issue。
  191. // fields 为空时返回 Jira 默认字段集合。
  192. func (j *JiraClient) GetIssue(ctx context.Context, issueKey string, fields ...string) (*jira.Issue, error) {
  193. if j == nil || j.client == nil {
  194. return nil, fmt.Errorf("jira 客户端未初始化")
  195. }
  196. if strings.TrimSpace(issueKey) == "" {
  197. return nil, fmt.Errorf("issueKey 不能为空")
  198. }
  199. opt := &jira.GetQueryOptions{}
  200. if len(fields) > 0 {
  201. opt.Fields = strings.Join(fields, ",")
  202. }
  203. issue, _, err := j.client.Issue.GetWithContext(ctx, issueKey, opt)
  204. if err != nil {
  205. return nil, fmt.Errorf("获取 issue 失败: %w", err)
  206. }
  207. return issue, nil
  208. }
  209. // SearchIssues 使用 JQL 搜索 Issue。
  210. // 返回值 total 为 Jira 检索结果总数(非当前页数量)。
  211. func (j *JiraClient) SearchIssues(ctx context.Context, jql string, startAt, maxResults int, fields ...string) (issues []jira.Issue, total int, err error) {
  212. if j == nil || j.client == nil {
  213. return nil, 0, fmt.Errorf("jira 客户端未初始化")
  214. }
  215. if strings.TrimSpace(jql) == "" {
  216. return nil, 0, fmt.Errorf("jql 不能为空")
  217. }
  218. opt := &jira.SearchOptions{StartAt: startAt, MaxResults: maxResults}
  219. if len(fields) > 0 {
  220. opt.Fields = fields
  221. }
  222. issueList, resp, err := j.client.Issue.SearchWithContext(ctx, jql, opt)
  223. if err != nil {
  224. return nil, 0, fmt.Errorf("搜索 issue 失败: %w", err)
  225. }
  226. if resp != nil {
  227. total = resp.Total
  228. }
  229. return issueList, total, nil
  230. }
  231. // AddComment 给指定 Issue 添加评论。
  232. func (j *JiraClient) AddComment(ctx context.Context, issueKey, commentBody string) (*jira.Comment, error) {
  233. if j == nil || j.client == nil {
  234. return nil, fmt.Errorf("jira 客户端未初始化")
  235. }
  236. if strings.TrimSpace(issueKey) == "" {
  237. return nil, fmt.Errorf("issueKey 不能为空")
  238. }
  239. if strings.TrimSpace(commentBody) == "" {
  240. return nil, fmt.Errorf("commentBody 不能为空")
  241. }
  242. comment := &jira.Comment{Body: commentBody}
  243. created, _, err := j.client.Issue.AddCommentWithContext(ctx, issueKey, comment)
  244. if err != nil {
  245. return nil, fmt.Errorf("添加评论失败: %w", err)
  246. }
  247. return created, nil
  248. }
  249. // GetTransitions 获取 Issue 可执行流转列表。
  250. func (j *JiraClient) GetTransitions(ctx context.Context, issueKey string) ([]jira.Transition, error) {
  251. if j == nil || j.client == nil {
  252. return nil, fmt.Errorf("jira 客户端未初始化")
  253. }
  254. if strings.TrimSpace(issueKey) == "" {
  255. return nil, fmt.Errorf("issueKey 不能为空")
  256. }
  257. transitions, _, err := j.client.Issue.GetTransitionsWithContext(ctx, issueKey)
  258. if err != nil {
  259. return nil, fmt.Errorf("获取流转列表失败: %w", err)
  260. }
  261. return transitions, nil
  262. }
  263. // TransitionIssue 执行 Issue 流转。
  264. // transitionID 可通过 GetTransitions 获取;comment 非空时会在流转时写入评论。
  265. func (j *JiraClient) TransitionIssue(ctx context.Context, issueKey, transitionID, comment string) error {
  266. if j == nil || j.client == nil {
  267. return fmt.Errorf("jira 客户端未初始化")
  268. }
  269. if strings.TrimSpace(issueKey) == "" {
  270. return fmt.Errorf("issueKey 不能为空")
  271. }
  272. if strings.TrimSpace(transitionID) == "" {
  273. return fmt.Errorf("transitionID 不能为空")
  274. }
  275. payload := jira.CreateTransitionPayload{
  276. Transition: jira.TransitionPayload{ID: transitionID},
  277. }
  278. if strings.TrimSpace(comment) != "" {
  279. payload.Update = jira.TransitionPayloadUpdate{
  280. Comment: []jira.TransitionPayloadComment{
  281. {
  282. Add: jira.TransitionPayloadCommentBody{Body: comment},
  283. },
  284. },
  285. }
  286. }
  287. _, err := j.client.Issue.DoTransitionWithPayloadWithContext(ctx, issueKey, payload)
  288. if err != nil {
  289. return fmt.Errorf("执行流转失败: %w", err)
  290. }
  291. return nil
  292. }
  293. // UploadAttachment 上传附件到指定 Issue。
  294. func (j *JiraClient) UploadAttachment(ctx context.Context, issueKey, fileName string, reader io.Reader) ([]jira.Attachment, error) {
  295. if j == nil || j.client == nil {
  296. return nil, fmt.Errorf("jira 客户端未初始化")
  297. }
  298. if strings.TrimSpace(issueKey) == "" {
  299. return nil, fmt.Errorf("issueKey 不能为空")
  300. }
  301. if strings.TrimSpace(fileName) == "" {
  302. return nil, fmt.Errorf("fileName 不能为空")
  303. }
  304. if reader == nil {
  305. return nil, fmt.Errorf("reader 不能为空")
  306. }
  307. attachments, _, err := j.client.Issue.PostAttachmentWithContext(ctx, issueKey, reader, fileName)
  308. if err != nil {
  309. return nil, fmt.Errorf("上传附件失败: %w", err)
  310. }
  311. if attachments == nil {
  312. return []jira.Attachment{}, nil
  313. }
  314. return *attachments, nil
  315. }
  316. // BuildCreateIssueRequest 构建创建 Issue 的请求体。
  317. // issueTypeName 示例: Task / Bug / Story。
  318. func BuildCreateIssueRequest(projectKey, issueTypeName, summary, description string) *jira.Issue {
  319. return &jira.Issue{
  320. Fields: &jira.IssueFields{
  321. Project: jira.Project{Key: projectKey},
  322. Type: jira.IssueType{
  323. Name: issueTypeName,
  324. },
  325. Summary: summary,
  326. Description: description,
  327. },
  328. }
  329. }