fleet/server/service/externalsvc/jira.go
2022-06-06 10:41:51 -04:00

147 lines
3.8 KiB
Go

package externalsvc
import (
"context"
"errors"
"net"
"net/http"
"strconv"
"time"
"github.com/andygrunwald/go-jira"
"github.com/cenkalti/backoff/v4"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
)
// Jira is a Jira client to be used to make requests to a jira external
// service.
type Jira struct {
client *jira.Client
opts JiraOptions
}
// JiraOptions defines the options to configure a Jira client.
type JiraOptions struct {
BaseURL string
BasicAuthUsername string
BasicAuthPassword string
ProjectKey string
}
// NewJiraClient returns a Jira client to use to make requests to a jira
// external service.
func NewJiraClient(opts *JiraOptions) (*Jira, error) {
tr := fleethttp.NewTransport()
basicAuth := &jira.BasicAuthTransport{
Username: opts.BasicAuthUsername,
Password: opts.BasicAuthPassword,
Transport: tr,
}
client, err := jira.NewClient(basicAuth.Client(), opts.BaseURL)
if err != nil {
return nil, err
}
return &Jira{
client: client,
opts: *opts,
}, nil
}
// GetProject returns the project details for the project key provided in the
// Jira client options. It can be used to test in one request the
// authentication and connection parameters to the Jira instance as well as the
// existence of the project.
func (j *Jira) GetProject(ctx context.Context) (*jira.Project, error) {
var proj *jira.Project
op := func() (*jira.Response, error) {
var (
err error
resp *jira.Response
)
proj, resp, err = j.client.Project.GetWithContext(ctx, j.opts.ProjectKey)
return resp, err
}
if err := doWithRetry(op); err != nil {
return nil, err
}
return proj, nil
}
// CreateJiraIssue creates an issue on the jira server targeted by the Jira client.
// It returns the created issue or an error.
func (j *Jira) CreateJiraIssue(ctx context.Context, issue *jira.Issue) (*jira.Issue, error) {
if issue.Fields == nil {
issue.Fields = &jira.IssueFields{}
}
issue.Fields.Project.Key = j.opts.ProjectKey
var createdIssue *jira.Issue
op := func() (*jira.Response, error) {
var (
err error
resp *jira.Response
)
createdIssue, resp, err = j.client.Issue.CreateWithContext(ctx, issue)
return resp, err
}
if err := doWithRetry(op); err != nil {
return nil, err
}
return createdIssue, nil
}
// JiraConfigMatches returns true if the jira client has been configured using
// those same options. The Jira in the name is required so that the interface
// method is not the same as the one for Zendesk (for mock or wrapper
// implementations).
func (j *Jira) JiraConfigMatches(opts *JiraOptions) bool {
return j.opts == *opts
}
// TODO: find approach to consolidate overlapping logic for jira and zendesk retries
func doWithRetry(fn func() (*jira.Response, error)) error {
op := func() error {
resp, err := fn()
if err == nil {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Temporary() || netErr.Timeout() {
// retryable error
return err
}
}
if resp.StatusCode >= http.StatusInternalServerError {
// 500+ status, can be worth retrying
return err
}
if resp.StatusCode == http.StatusTooManyRequests {
// handle 429 rate-limits, see
// https://developer.atlassian.com/cloud/jira/platform/rate-limiting/
// for details.
rawAfter := resp.Header.Get("Retry-After")
afterSecs, err := strconv.ParseInt(rawAfter, 10, 0)
if err == nil && (time.Duration(afterSecs)*time.Second) < maxWaitForRetryAfter {
// the retry-after duration is reasonable, wait for it and return a
// retryable error so that we try again.
time.Sleep(time.Duration(afterSecs) * time.Second)
return errors.New("retry after requested delay")
}
}
// at this point, this is a non-retryable error
return backoff.Permanent(err)
}
boff := backoff.WithMaxRetries(backoff.NewConstantBackOff(retryBackoff), uint64(maxRetries))
return backoff.Retry(op, boff)
}