diff --git a/cli/clients.go b/cli/clients.go deleted file mode 100644 index cbbefce..0000000 --- a/cli/clients.go +++ /dev/null @@ -1,139 +0,0 @@ -package cli - -import ( - "context" - "errors" - "io/ioutil" - - "github.com/andygrunwald/go-jira" - "github.com/cenkalti/backoff" - "github.com/coreos/issue-sync/cfg" - "github.com/google/go-github/github" - "golang.org/x/oauth2" -) - -// GetErrorBody reads the HTTP response body of a JIRA API response, -// logs it as an error, and returns an error object with the contents -// of the body. If an error occurs during reading, that error is -// instead printed and returned. This function closes the body for -// further reading. -func GetErrorBody(config cfg.Config, res *jira.Response) error { - log := config.GetLogger() - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) - if err != nil { - log.Errorf("Error occured trying to read error body: %v", err) - return err - } else { - log.Debugf("Error body: %s", body) - return errors.New(string(body)) - } -} - -// MakeGHRequest takes an API function from the GitHub library -// and calls it with exponential backoff. If the function succeeds, it -// stores the value in the ret parameter, and returns the HTTP response -// from the function, and a nil error. If it continues to fail until -// a maximum time is reached, the ret parameter is returned as is, and a -// nil HTTP response and a timeout error are returned. -// -// It is nearly identical to MakeJIRARequest, but returns a GitHub API response. -func MakeGHRequest(config cfg.Config, f func() (interface{}, *github.Response, error)) (interface{}, *github.Response, error) { - var ret interface{} - var res *github.Response - var err error - - op := func() error { - ret, res, err = f() - return err - } - - b := backoff.NewExponentialBackOff() - b.MaxElapsedTime = config.GetTimeout() - - er := backoff.Retry(op, b) - if er != nil { - return nil, nil, er - } - - return ret, res, err -} - -// MakeJIRARequest takes an API function from the JIRA library -// and calls it with exponential backoff. If the function succeeds, it -// stores the value in the ret parameter, and returns the HTTP response -// from the function, and a nil error. If it continues to fail until -// a maximum time is reached, the ret parameter is returned as is, and a -// nil HTTP response and a timeout error are returned. -// -// It is nearly identical to MakeGHRequest, but returns a JIRA API response. -func MakeJIRARequest(config cfg.Config, f func() (interface{}, *jira.Response, error)) (interface{}, *jira.Response, error) { - var ret interface{} - var res *jira.Response - var err error - - op := func() error { - ret, res, err = f() - return err - } - - b := backoff.NewExponentialBackOff() - b.MaxElapsedTime = config.GetTimeout() - - er := backoff.Retry(op, b) - if er != nil { - return ret, res, er - } - - return ret, res, err -} - -// GetGitHubClient initializes a GitHub API cli with an OAuth cli for authentication, -// then makes an API request to confirm that the service is running and the auth token -// is valid. -func GetGitHubClient(config cfg.Config) (*github.Client, error) { - log := config.GetLogger() - - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: config.GetConfigString("github-token")}, - ) - tc := oauth2.NewClient(ctx, ts) - - client := github.NewClient(tc) - - // Make a request so we can check that we can connect fine. - _, res, err := MakeGHRequest(config, func() (interface{}, *github.Response, error) { - return client.RateLimits(ctx) - }) - if err != nil { - log.Errorf("Error connecting to GitHub; check your token. Error: %v", err) - return nil, err - } else if err = github.CheckResponse(res.Response); err != nil { - log.Errorf("Error connecting to GitHub; check your token. Error: %v", err) - return nil, err - } - - log.Debug("Successfully connected to GitHub.") - return client, nil -} - -// GetJIRAClient initializes a JIRA API cli, then sets the Basic Auth credentials -// passed to it. (OAuth token support is planned.) -// -// The validity of the cli and its authentication are not checked here. One way -// to check them would be to call cfg.LoadJIRAConfig() after this function. -func GetJIRAClient(config cfg.Config) (*jira.Client, error) { - log := config.GetLogger() - - client, err := jira.NewClient(nil, config.GetConfigString("jira-uri")) - if err != nil { - log.Errorf("Error initializing JIRA cli; check your base URI. Error: %v", err) - return nil, err - } - - client.Authentication.SetBasicAuth(config.GetConfigString("jira-user"), config.GetConfigString("jira-pass")) - - log.Debug("JIRA cli initialized") - return client, nil -} diff --git a/cmd/root.go b/cmd/root.go index ca39922..6625211 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,8 +5,8 @@ import ( "github.com/Sirupsen/logrus" "github.com/coreos/issue-sync/cfg" - "github.com/coreos/issue-sync/cli" "github.com/coreos/issue-sync/lib" + "github.com/coreos/issue-sync/lib/clients" "github.com/spf13/cobra" ) @@ -30,20 +30,16 @@ var RootCmd = &cobra.Command{ return err } - ghClient, err := cli.GetGitHubClient(config) + ghClient, err := clients.NewGitHubClient(config) if err != nil { return err } - jiraClient, err := cli.GetJIRAClient(config) + jiraClient, err := clients.NewJIRAClient(&config) if err != nil { return err } - if err := config.LoadJIRAConfig(*jiraClient); err != nil { - return err - } - - if err := lib.CompareIssues(config, *ghClient, *jiraClient); err != nil { + if err := lib.CompareIssues(config, ghClient, jiraClient); err != nil { return err } diff --git a/lib/clients/github.go b/lib/clients/github.go new file mode 100644 index 0000000..4c2d134 --- /dev/null +++ b/lib/clients/github.go @@ -0,0 +1,190 @@ +package clients + +import ( + "context" + "errors" + "fmt" + + "github.com/cenkalti/backoff" + "github.com/coreos/issue-sync/cfg" + "github.com/google/go-github/github" + "golang.org/x/oauth2" +) + +// GitHubClient is a wrapper around the GitHub API Client library we +// use. It allows us to swap in other implementations, such as a dry run +// clients, or mock clients for testing. +type GitHubClient interface { + ListIssues() ([]*github.Issue, error) + ListComments(issue github.Issue) ([]*github.IssueComment, error) + GetUser(login string) (github.User, error) + GetRateLimits() (github.RateLimits, error) +} + +// realGHClient is a standard GitHub clients, that actually makes all of the +// requests against the GitHub REST API. It is the canonical implementation +// of GitHubClient. +type realGHClient struct { + config cfg.Config + client github.Client +} + +// ListIssues returns the list of GitHub issues since the last run of the tool. +func (g realGHClient) ListIssues() ([]*github.Issue, error) { + log := g.config.GetLogger() + + ctx := context.Background() + + user, repo := g.config.GetRepo() + + i, _, err := g.request(func() (interface{}, *github.Response, error) { + return g.client.Issues.ListByRepo(ctx, user, repo, &github.IssueListByRepoOptions{ + Since: g.config.GetSinceParam(), + State: "all", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + }) + }) + if err != nil { + return nil, err + } + ghIssues, ok := i.([]*github.Issue) + if !ok { + log.Errorf("Get GitHub issues did not return issues! Got: %v", i) + return nil, errors.New(fmt.Sprintf("Get GitHub issues failed: expected []*github.Issue; got %T", i)) + } + + log.Debug("Collected all GitHub issues") + + return ghIssues, nil +} + +// ListComments returns the list of all comments on a GitHub issue in +// ascending order of creation. +func (g realGHClient) ListComments(issue github.Issue) ([]*github.IssueComment, error) { + log := g.config.GetLogger() + + ctx := context.Background() + user, repo := g.config.GetRepo() + c, _, err := g.request(func() (interface{}, *github.Response, error) { + return g.client.Issues.ListComments(ctx, user, repo, issue.GetNumber(), &github.IssueListCommentsOptions{ + Sort: "created", + Direction: "asc", + }) + }) + if err != nil { + log.Errorf("Error retrieving GitHub comments for issue #%d. Error: %v.", issue.GetNumber(), err) + return nil, err + } + comments, ok := c.([]*github.IssueComment) + if !ok { + log.Errorf("Get GitHub comments did not return comments! Got: %v", c) + return nil, errors.New(fmt.Sprintf("Get GitHub comments failed: expected []*github.IssueComment; got %T", c)) + } + + return comments, nil +} + +// GetUser returns a GitHub user from its login. +func (g realGHClient) GetUser(login string) (github.User, error) { + log := g.config.GetLogger() + + u, _, err := g.request(func() (interface{}, *github.Response, error) { + return g.client.Users.Get(context.Background(), login) + }) + + if err != nil { + log.Errorf("Error retrieving GitHub user %s. Error: %v", login, err) + } + + user, ok := u.(*github.User) + if !ok { + log.Errorf("Get GitHub user did not return user! Got: %v", u) + return github.User{}, errors.New(fmt.Sprintf("Get GitHub user failed: expected *github.User; got %T", u)) + } + + return *user, nil +} + +// GetRateLimits returns the current rate limits on the GitHub API. This is a +// simple and lightweight request that can also be used simply for testing the API. +func (g realGHClient) GetRateLimits() (github.RateLimits, error) { + log := g.config.GetLogger() + + ctx := context.Background() + + rl, _, err := g.request(func() (interface{}, *github.Response, error) { + return g.client.RateLimits(ctx) + }) + if err != nil { + log.Errorf("Error connecting to GitHub; check your token. Error: %v", err) + return github.RateLimits{}, err + } + rate, ok := rl.(*github.RateLimits) + if !ok { + log.Errorf("Get GitHub rate limits did not return rate limits! Got: %v", rl) + return github.RateLimits{}, errors.New(fmt.Sprintf("Get GitHub rate limits failed: expected *github.RateLimits; got %T", rl)) + } + + return *rate, nil +} + +// request takes an API function from the GitHub library +// and calls it with exponential backoff. If the function succeeds, it +// returns the expected value and the GitHub API response, as well as a nil +// error. If it continues to fail until a maximum time is reached, it returns +// a nil result as well as the returned HTTP response and a timeout error. +func (g realGHClient) request(f func() (interface{}, *github.Response, error)) (interface{}, *github.Response, error) { + var ret interface{} + var res *github.Response + var err error + + op := func() error { + ret, res, err = f() + return err + } + + b := backoff.NewExponentialBackOff() + b.MaxElapsedTime = g.config.GetTimeout() + + er := backoff.Retry(op, b) + if er != nil { + return nil, nil, er + } + + return ret, res, err +} + +// NewGitHubClient creates a GitHubClient and returns it; which +// implementation it uses depends on the configuration of this +// run. For example, a dry-run clients may be created which does +// not make any requests that would change anything on the server, +// but instead simply prints out the actions that it's asked to take. +func NewGitHubClient(config cfg.Config) (GitHubClient, error) { + var ret GitHubClient + + log := config.GetLogger() + + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: config.GetConfigString("github-token")}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + + ret = realGHClient{ + config: config, + client: *client, + } + + // Make a request so we can check that we can connect fine. + _, err := ret.GetRateLimits() + if err != nil { + return realGHClient{}, err + } + log.Debug("Successfully connected to GitHub.") + + return ret, nil +} diff --git a/lib/clients/jira.go b/lib/clients/jira.go new file mode 100644 index 0000000..4f202bf --- /dev/null +++ b/lib/clients/jira.go @@ -0,0 +1,543 @@ +package clients + +import ( + "errors" + "fmt" + "io/ioutil" + "regexp" + + "github.com/andygrunwald/go-jira" + "github.com/cenkalti/backoff" + "github.com/coreos/issue-sync/cfg" + "github.com/google/go-github/github" +) + +// commentDateFormat is the format used in the headers of JIRA comments +const commentDateFormat = "15:04 PM, January 2 2006" + +// getErrorBody reads the HTTP response body of a JIRA API response, +// logs it as an error, and returns an error object with the contents +// of the body. If an error occurs during reading, that error is +// instead printed and returned. This function closes the body for +// further reading. +func getErrorBody(config cfg.Config, res *jira.Response) error { + log := config.GetLogger() + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Errorf("Error occured trying to read error body: %v", err) + return err + } else { + log.Debugf("Error body: %s", body) + return errors.New(string(body)) + } +} + +// JIRAClient is a wrapper around the JIRA API clients library we +// use. It allows us to hide implementation details such as backoff +// as well as swap in other implementations, such as for dry run +// or test mocking. +type JIRAClient interface { + ListIssues(ids string) ([]jira.Issue, error) + GetIssue(key string) (jira.Issue, error) + CreateIssue(issue jira.Issue) (jira.Issue, error) + UpdateIssue(issue jira.Issue) (jira.Issue, error) + CreateComment(issue jira.Issue, comment github.IssueComment, github GitHubClient) (jira.Comment, error) + UpdateComment(issue jira.Issue, id string, comment github.IssueComment, github GitHubClient) (jira.Comment, error) +} + +// NewJIRAClient creates a new JIRAClient and configures it with +// the config object provided. The type of clients created depends +// on the configuration; currently, it creates either a standard +// clients, or a dry-run clients. +func NewJIRAClient(config *cfg.Config) (JIRAClient, error) { + log := config.GetLogger() + + var j JIRAClient + + client, err := jira.NewClient(nil, config.GetConfigString("jira-uri")) + if err != nil { + log.Errorf("Error initializing JIRA clients; check your base URI. Error: %v", err) + return dryrunJIRAClient{}, err + } + client.Authentication.SetBasicAuth(config.GetConfigString("jira-user"), config.GetConfigString("jira-pass")) + + log.Debug("JIRA clients initialized") + + config.LoadJIRAConfig(*client) + + if config.IsDryRun() { + j = dryrunJIRAClient{ + config: *config, + client: *client, + } + } else { + j = realJIRAClient{ + config: *config, + client: *client, + } + } + + return j, nil +} + +// realJIRAClient is a standard JIRA clients, which actually makes +// of the requests against the JIRA REST API. It is the canonical +// implementation of JIRAClient. +type realJIRAClient struct { + config cfg.Config + client jira.Client +} + +// ListIssues returns a list of JIRA issues on the configured project which +// have GitHub IDs in the provided list. `ids` should be a comma-separated +// list of GitHub IDs. +func (j realJIRAClient) ListIssues(ids string) ([]jira.Issue, error) { + log := j.config.GetLogger() + + jql := fmt.Sprintf("project='%s' AND cf[%s] in (%s)", + j.config.GetProjectKey(), j.config.GetFieldID(cfg.GitHubID), ids) + + ji, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Search(jql, nil) + }) + if err != nil { + log.Errorf("Error retrieving JIRA issues: %v", err) + return nil, getErrorBody(j.config, res) + } + jiraIssues, ok := ji.([]jira.Issue) + if !ok { + log.Errorf("Get JIRA issues did not return issues! Got: %v", ji) + return nil, errors.New(fmt.Sprintf("Get JIRA issues failed: expected []jira.Issue; got %T", ji)) + } + + return jiraIssues, nil +} + +// GetIssue returns a single JIRA issue within the configured project +// according to the issue key (e.g. "PROJ-13"). +func (j realJIRAClient) GetIssue(key string) (jira.Issue, error) { + log := j.config.GetLogger() + + i, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Get(key, nil) + }) + if err != nil { + log.Errorf("Error retrieving JIRA issue: %v", err) + return jira.Issue{}, getErrorBody(j.config, res) + } + issue, ok := i.(*jira.Issue) + if !ok { + log.Errorf("Get JIRA issue did not return issue! Got %v", i) + return jira.Issue{}, errors.New(fmt.Sprintf("Get JIRA issue failed: expected *jira.Issue; got %T", i)) + } + + return *issue, nil +} + +// CreateIssue creates a new JIRA issue according to the fields provided in +// the provided issue object. It returns the created issue, with all the +// fields provided (including e.g. ID and Key). +func (j realJIRAClient) CreateIssue(issue jira.Issue) (jira.Issue, error) { + log := j.config.GetLogger() + + i, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Create(&issue) + }) + if err != nil { + log.Errorf("Error creating JIRA issue: %v", err) + return jira.Issue{}, getErrorBody(j.config, res) + } + is, ok := i.(*jira.Issue) + if !ok { + log.Errorf("Create JIRA issue did not return issue! Got: %v", i) + return jira.Issue{}, errors.New(fmt.Sprintf("Create JIRA issue failed: expected *jira.Issue; got %T", i)) + } + + // The JIRA create endpoint doesn't return more in the issue than just + // the key and ID, so call the get endpoint to get a full issue object + issue, err = j.GetIssue(is.Key) + if err != nil { + return jira.Issue{}, err + } + + return issue, nil +} + +// UpdateIssue updates a given issue (identified by the Key field of the provided +// issue object) with the fields on the provided issue. It returns the updated +// issue as it exists on JIRA. +func (j realJIRAClient) UpdateIssue(issue jira.Issue) (jira.Issue, error) { + log := j.config.GetLogger() + + i, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Update(&issue) + }) + if err != nil { + log.Errorf("Error updating JIRA issue %s: %v", issue.Key, err) + return jira.Issue{}, getErrorBody(j.config, res) + } + is, ok := i.(*jira.Issue) + if !ok { + log.Errorf("Update JIRA issue did not return issue! Got: %v", i) + return jira.Issue{}, errors.New(fmt.Sprintf("Update JIRA issue failed: expected *jira.Issue; got %T", i)) + } + + // The JIRA update endpoint doesn't return more in the issue than just + // the key and ID, so call the get endpoint to get a full issue object + issue, err = j.GetIssue(is.Key) + if err != nil { + return jira.Issue{}, err + } + + return issue, nil +} + +// CreateComment adds a comment to the provided JIRA issue using the fields from +// the provided GitHub comment. It then returns the created comment. +func (j realJIRAClient) CreateComment(issue jira.Issue, comment github.IssueComment, github GitHubClient) (jira.Comment, error) { + log := j.config.GetLogger() + + user, err := github.GetUser(comment.User.GetLogin()) + if err != nil { + return jira.Comment{}, err + } + + body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", comment.GetID(), user.GetLogin()) + if user.GetName() != "" { + body = fmt.Sprintf("%s (%s)", body, user.GetName()) + } + body = fmt.Sprintf( + "%s at %s:\n\n%s", + body, + comment.CreatedAt.Format(commentDateFormat), + comment.GetBody(), + ) + jComment := jira.Comment{ + Body: body, + } + + com, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.AddComment(issue.ID, &jComment) + }) + if err != nil { + log.Errorf("Error creating JIRA comment on issue %s. Error: %v", issue.Key, err) + return jira.Comment{}, getErrorBody(j.config, res) + } + co, ok := com.(*jira.Comment) + if !ok { + log.Errorf("Create JIRA comment did not return comment! Got: %v", com) + return jira.Comment{}, errors.New(fmt.Sprintf("Create JIRA comment failed: expected *jira.Comment; got %T", com)) + } + return *co, nil +} + +// UpdateComment updates a comment (identified by the `id` parameter) on a given +// JIRA with a new body from the fields of the given GitHub comment. It returns +// the updated comment. +func (j realJIRAClient) UpdateComment(issue jira.Issue, id string, comment github.IssueComment, github GitHubClient) (jira.Comment, error) { + log := j.config.GetLogger() + + user, err := github.GetUser(comment.User.GetLogin()) + if err != nil { + return jira.Comment{}, err + } + + body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", comment.GetID(), user.GetLogin()) + if user.GetName() != "" { + body = fmt.Sprintf("%s (%s)", body, user.GetName()) + } + body = fmt.Sprintf( + "%s at %s:\n\n%s", + body, + comment.CreatedAt.Format(commentDateFormat), + comment.GetBody(), + ) + + // As it is, the JIRA API we're using doesn't have any way to update comments natively. + // So, we have to build the request ourselves. + request := struct { + Body string `json:"body"` + }{ + Body: body, + } + + req, err := j.client.NewRequest("PUT", fmt.Sprintf("rest/api/2/issue/%s/comment/%s", issue.Key, id), request) + if err != nil { + log.Errorf("Error creating comment update request: %s", err) + return jira.Comment{}, err + } + + com, res, err := j.request(func() (interface{}, *jira.Response, error) { + res, err := j.client.Do(req, nil) + return nil, res, err + }) + if err != nil { + log.Errorf("Error updating comment: %v", err) + return jira.Comment{}, getErrorBody(j.config, res) + } + co, ok := com.(*jira.Comment) + if !ok { + log.Errorf("Update JIRA comment did not return comment! Got: %v", com) + return jira.Comment{}, errors.New(fmt.Sprintf("Update JIRA comment failed: expected *jira.Comment; got %T", com)) + } + return *co, nil +} + +// request takes an API function from the JIRA library +// and calls it with exponential backoff. If the function succeeds, it +// returns the expected value and the JIRA API response, as well as a nil +// error. If it continues to fail until a maximum time is reached, it returns +// a nil result as well as the returned HTTP response and a timeout error. +func (j realJIRAClient) request(f func() (interface{}, *jira.Response, error)) (interface{}, *jira.Response, error) { + var ret interface{} + var res *jira.Response + var err error + + op := func() error { + ret, res, err = f() + return err + } + + b := backoff.NewExponentialBackOff() + b.MaxElapsedTime = j.config.GetTimeout() + + er := backoff.Retry(op, b) + if er != nil { + return ret, res, er + } + + return ret, res, err +} + +// dryrunJIRAClient is an implementation of JIRAClient which performs all +// GET requests the same as the realJIRAClient, but does not perform any +// unsafe requests which may modify server data, instead printing out the +// actions it is asked to perform without making the request. +type dryrunJIRAClient struct { + config cfg.Config + client jira.Client +} + +// newlineReplaceRegex is a regex to match both "\r\n" and just "\n" newline styles, +// in order to allow us to escape both sequences cleanly in the output of a dry run. +var newlineReplaceRegex = regexp.MustCompile("\r?\n") + +// truncate is a utility function to replace all the newlines in +// the string with the characters "\n", then truncate it to no +// more than 50 characters +func truncate(s string, length int) string { + if s == "" { + return "empty" + } + + s = newlineReplaceRegex.ReplaceAllString(s, "\\n") + if len(s) <= length { + return s + } + return fmt.Sprintf("%s...", s[0:length]) +} + +// ListIssues returns a list of JIRA issues on the configured project which +// have GitHub IDs in the provided list. `ids` should be a comma-separated +// list of GitHub IDs. +// +// This function is identical to that in realJIRAClient. +func (j dryrunJIRAClient) ListIssues(ids string) ([]jira.Issue, error) { + log := j.config.GetLogger() + + jql := fmt.Sprintf("project='%s' AND cf[%s] in (%s)", + j.config.GetProjectKey(), j.config.GetFieldID(cfg.GitHubID), ids) + + ji, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Search(jql, nil) + }) + if err != nil { + log.Errorf("Error retrieving JIRA issues: %v", err) + return nil, getErrorBody(j.config, res) + } + jiraIssues, ok := ji.([]jira.Issue) + if !ok { + log.Errorf("Get JIRA issues did not return issues! Got: %v", ji) + return nil, errors.New(fmt.Sprintf("Get JIRA issues failed: expected []jira.Issue; got %T", ji)) + } + + return jiraIssues, nil +} + +// GetIssue returns a single JIRA issue within the configured project +// according to the issue key (e.g. "PROJ-13"). +// +// This function is identical to that in realJIRAClient. +func (j dryrunJIRAClient) GetIssue(key string) (jira.Issue, error) { + log := j.config.GetLogger() + + i, res, err := j.request(func() (interface{}, *jira.Response, error) { + return j.client.Issue.Get(key, nil) + }) + if err != nil { + log.Errorf("Error retrieving JIRA issue: %v", err) + return jira.Issue{}, getErrorBody(j.config, res) + } + issue, ok := i.(*jira.Issue) + if !ok { + log.Errorf("Get JIRA issue did not return issue! Got %v", i) + return jira.Issue{}, errors.New(fmt.Sprintf("Get JIRA issue failed: expected *jira.Issue; got %T", i)) + } + + return *issue, nil +} + +// CreateIssue prints out the fields that would be set on a new issue were +// it to be created according to the provided issue object. It returns the +// provided issue object as-is. +func (j dryrunJIRAClient) CreateIssue(issue jira.Issue) (jira.Issue, error) { + log := j.config.GetLogger() + + fields := issue.Fields + + log.Info("") + log.Info("Create new JIRA issue:") + log.Infof(" Summary: %s", fields.Summary) + log.Infof(" Description: %s", truncate(fields.Description, 50)) + log.Infof(" GitHub ID: %d", fields.Unknowns[j.config.GetFieldKey(cfg.GitHubID)]) + log.Infof(" GitHub Number: %d", fields.Unknowns[j.config.GetFieldKey(cfg.GitHubNumber)]) + log.Infof(" Labels: %s", fields.Unknowns[j.config.GetFieldKey(cfg.GitHubLabels)]) + log.Infof(" State: %s", fields.Unknowns[j.config.GetFieldKey(cfg.GitHubStatus)]) + log.Infof(" Reporter: %s", fields.Unknowns[j.config.GetFieldKey(cfg.GitHubReporter)]) + log.Info("") + + return issue, nil +} + +// UpdateIssue prints out the fields that would be set on a JIRA issue +// (identified by issue.Key) were it to be updated according to the issue +// object. It then returns the provided issue object as-is. +func (j dryrunJIRAClient) UpdateIssue(issue jira.Issue) (jira.Issue, error) { + log := j.config.GetLogger() + + fields := issue.Fields + + log.Info("") + log.Infof("Update JIRA issue %s:", issue.Key) + log.Infof(" Summary: %s", fields.Summary) + log.Infof(" Description: %s", truncate(fields.Description, 50)) + key := j.config.GetFieldKey(cfg.GitHubLabels) + if labels, err := fields.Unknowns.String(key); err == nil { + log.Infof(" Labels: %s", labels) + } + key = j.config.GetFieldKey(cfg.GitHubStatus) + if state, err := fields.Unknowns.String(key); err == nil { + log.Infof(" State: %s", state) + } + log.Info("") + + return issue, nil +} + +// CreateComment prints the body that would be set on a new comment if it were +// to be created according to the fields of the provided GitHub comment. It then +// returns a comment object containing the body that would be used. +func (j dryrunJIRAClient) CreateComment(issue jira.Issue, comment github.IssueComment, github GitHubClient) (jira.Comment, error) { + log := j.config.GetLogger() + + user, err := github.GetUser(comment.User.GetLogin()) + if err != nil { + return jira.Comment{}, err + } + + body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", comment.GetID(), user.GetLogin()) + if user.GetName() != "" { + body = fmt.Sprintf("%s (%s)", body, user.GetName()) + } + body = fmt.Sprintf( + "%s at %s:\n\n%s", + body, + comment.CreatedAt.Format(commentDateFormat), + comment.GetBody(), + ) + + log.Info("") + log.Infof("Create comment on JIRA issue %s:", issue.Key) + log.Infof(" GitHub ID: %d", comment.GetID()) + if user.GetName() != "" { + log.Infof(" User: %s (%s)", user.GetLogin(), user.GetName()) + } else { + log.Infof(" User: %s", user.GetLogin()) + } + log.Infof(" Posted at: %s", comment.CreatedAt.Format(commentDateFormat)) + log.Infof(" Body: %s", truncate(comment.GetBody(), 100)) + log.Info("") + + return jira.Comment{ + Body: body, + }, nil +} + +// UpdateComment prints the body that would be set on a comment were it to be +// updated according to the provided GitHub comment. It then returns a comment +// object containing the body that would be used. +func (j dryrunJIRAClient) UpdateComment(issue jira.Issue, id string, comment github.IssueComment, github GitHubClient) (jira.Comment, error) { + log := j.config.GetLogger() + + user, err := github.GetUser(comment.User.GetLogin()) + if err != nil { + return jira.Comment{}, err + } + + body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", comment.GetID(), user.GetLogin()) + if user.GetName() != "" { + body = fmt.Sprintf("%s (%s)", body, user.GetName()) + } + body = fmt.Sprintf( + "%s at %s:\n\n%s", + body, + comment.CreatedAt.Format(commentDateFormat), + comment.GetBody(), + ) + + log.Info("") + log.Infof("Update JIRA comment %s on issue %s:", id, issue.Key) + log.Infof(" GitHub ID: %d", comment.GetID()) + if user.GetName() != "" { + log.Infof(" User: %s (%s)", user.GetLogin(), user.GetName()) + } else { + log.Infof(" User: %s", user.GetLogin()) + } + log.Infof(" Posted at: %s", comment.CreatedAt.Format(commentDateFormat)) + log.Infof(" Body: %s", truncate(comment.GetBody(), 100)) + log.Info("") + + return jira.Comment{ + ID: id, + Body: body, + }, nil +} + +// request takes an API function from the JIRA library +// and calls it with exponential backoff. If the function succeeds, it +// returns the expected value and the JIRA API response, as well as a nil +// error. If it continues to fail until a maximum time is reached, it returns +// a nil result as well as the returned HTTP response and a timeout error. +// +// This function is identical to that in realJIRAClient. +func (j dryrunJIRAClient) request(f func() (interface{}, *jira.Response, error)) (interface{}, *jira.Response, error) { + var ret interface{} + var res *jira.Response + var err error + + op := func() error { + ret, res, err = f() + return err + } + + b := backoff.NewExponentialBackOff() + b.MaxElapsedTime = j.config.GetTimeout() + + er := backoff.Retry(op, b) + if er != nil { + return ret, res, er + } + + return ret, res, err +} diff --git a/lib/comments.go b/lib/comments.go index f3ae368..943f3f9 100644 --- a/lib/comments.go +++ b/lib/comments.go @@ -1,15 +1,12 @@ package lib import ( - "context" - "errors" - "fmt" "regexp" "strconv" "github.com/andygrunwald/go-jira" "github.com/coreos/issue-sync/cfg" - "github.com/coreos/issue-sync/cli" + "github.com/coreos/issue-sync/lib/clients" "github.com/google/go-github/github" ) @@ -26,35 +23,34 @@ var jCommentIDRegex = regexp.MustCompile("^Comment \\(ID (\\d+)\\)") // CreateComments takes a GitHub issue, and retrieves all of its comments. It then // matches each one to a comment in `existing`. If it finds a match, it calls // UpdateComment; if it doesn't, it calls CreateComment. -func CompareComments(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, existing []jira.Comment, ghClient github.Client, jClient jira.Client) error { +func CompareComments(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghClient clients.GitHubClient, jClient clients.JIRAClient) error { log := config.GetLogger() - if *ghIssue.Comments == 0 { + if ghIssue.GetComments() == 0 { log.Debugf("Issue #%d has no comments, skipping.", *ghIssue.Number) return nil } - ctx := context.Background() - user, repo := config.GetRepo() - c, _, err := cli.MakeGHRequest(config, func() (interface{}, *github.Response, error) { - return ghClient.Issues.ListComments(ctx, user, repo, *ghIssue.Number, &github.IssueListCommentsOptions{ - Sort: "created", - Direction: "asc", - }) - }) + ghComments, err := ghClient.ListComments(ghIssue) if err != nil { - log.Errorf("Error retrieving GitHub comments for issue #%d. Error: %v.", *ghIssue.Number, err) return err } - comments, ok := c.([]*github.IssueComment) - if !ok { - log.Errorf("Get GitHub comments did not return comments! Got: %v", c) - return errors.New(fmt.Sprintf("Get GitHub comments failed: expected []*github.IssueComment; got %T", c)) + + var jComments []jira.Comment + if jIssue.Fields.Comments == nil { + log.Debugf("JIRA issue %s has no comments.", jIssue.Key) + } else { + commentPtrs := jIssue.Fields.Comments.Comments + jComments = make([]jira.Comment, len(commentPtrs)) + for i, v := range commentPtrs { + jComments[i] = *v + } + log.Debugf("JIRA issue %s has %d comments", jIssue.Key, len(jComments)) } - for _, ghComment := range comments { + for _, ghComment := range ghComments { found := false - for _, jComment := range existing { + for _, jComment := range jComments { if !jCommentIDRegex.MatchString(jComment.Body) { continue } @@ -73,9 +69,12 @@ func CompareComments(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, continue } - if err := CreateComment(config, *ghComment, jIssue, ghClient, jClient); err != nil { + comment, err := jClient.CreateComment(jIssue, *ghComment, ghClient) + if err != nil { return err } + + log.Debugf("Created JIRA comment %s.", comment.ID) } log.Debugf("Copied comments from GH issue #%d to JIRA issue %s.", *ghIssue.Number, jIssue.Key) @@ -84,143 +83,23 @@ func CompareComments(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, // UpdateComment compares the body of a GitHub comment with the body (minus header) // of the JIRA comment, and updates the JIRA comment if necessary. -func UpdateComment(config cfg.Config, ghComment github.IssueComment, jComment jira.Comment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { +func UpdateComment(config cfg.Config, ghComment github.IssueComment, jComment jira.Comment, jIssue jira.Issue, ghClient clients.GitHubClient, jClient clients.JIRAClient) error { log := config.GetLogger() // fields[0] is the whole body, 1 is the ID, 2 is the username, 3 is the real name (or "" if none) // 4 is the date, and 5 is the real body fields := jCommentRegex.FindStringSubmatch(jComment.Body) - if fields[5] == *ghComment.Body { + if fields[5] == ghComment.GetBody() { return nil } - u, _, err := cli.MakeGHRequest(config, func() (interface{}, *github.Response, error) { - return ghClient.Users.Get(context.Background(), *ghComment.User.Login) - }) - if err != nil { - log.Errorf("Error retrieving GitHub user %s. Error: %v", *ghComment.User.Login, err) - } - user, ok := u.(*github.User) - if !ok { - log.Errorf("Get GitHub user did not return user! Got: %v", u) - return errors.New(fmt.Sprintf("Get GitHub user failed: expected *github.User; got %T", u)) - } - - body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", *ghComment.ID, user.GetLogin()) - if user.GetName() != "" { - body = fmt.Sprintf("%s (%s)", body, user.GetName()) - } - body = fmt.Sprintf( - "%s at %s:\n\n%s", - body, - ghComment.CreatedAt.Format(commentDateFormat), - *ghComment.Body, - ) - - // As it is, the JIRA API we're using doesn't have any way to update comments natively. - // So, we have to build the request ourselves. - - request := struct { - Body string `json:"body"` - }{ - Body: body, - } - - if !config.IsDryRun() { - req, err := jClient.NewRequest("PUT", fmt.Sprintf("rest/api/2/issue/%s/comment/%s", jIssue.Key, jComment.ID), request) - if err != nil { - log.Errorf("Error creating comment update request: %s", err) - return err - } - - _, res, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - res, err := jClient.Do(req, nil) - return nil, res, err - }) - if err != nil { - log.Errorf("Error updating comment: %v", err) - return cli.GetErrorBody(config, res) - } - } else { - log.Info("") - log.Infof("Update JIRA comment %s on issue %s:", jComment.ID, jIssue.Key) - if request.Body == "" { - log.Info(" Body: empty") - } else { - request.Body = newlineReplaceRegex.ReplaceAllString(request.Body, "\\n") - if len(request.Body) <= 150 { - log.Infof(" Body: %s", request.Body) - } else { - log.Infof(" Body: %s...", request.Body[0:150]) - } - } - log.Info("") - } - - return nil -} - -// CreateComment uses the ID, poster username, poster name, created at time, and body -// of a GitHub comment to generate the body of a JIRA comment, then creates it in the -// API. -func CreateComment(config cfg.Config, ghComment github.IssueComment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { - log := config.GetLogger() - - u, _, err := cli.MakeGHRequest(config, func() (interface{}, *github.Response, error) { - return ghClient.Users.Get(context.Background(), *ghComment.User.Login) - }) + comment, err := jClient.UpdateComment(jIssue, jComment.ID, ghComment, ghClient) if err != nil { - log.Errorf("Error retrieving GitHub user %s. Error: %v", *ghComment.User.Login, err) return err } - user, ok := u.(*github.User) - if !ok { - log.Errorf("Get GitHub user did not return user! Got: %v", u) - return errors.New(fmt.Sprintf("Get GitHub user failed: expected *github.User; got %T", u)) - } - body := fmt.Sprintf("Comment (ID %d) from GitHub user %s", *ghComment.ID, user.GetLogin()) - if user.GetName() != "" { - body = fmt.Sprintf("%s (%s)", body, user.GetName()) - } - body = fmt.Sprintf( - "%s at %s:\n\n%s", - body, - ghComment.CreatedAt.Format(commentDateFormat), - *ghComment.Body, - ) - jComment := &jira.Comment{ - Body: body, - } - - if !config.IsDryRun() { - _, res, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - return jClient.Issue.AddComment(jIssue.ID, jComment) - }) - if err != nil { - log.Errorf("Error creating JIRA comment on issue %s. Error: %v", jIssue.Key, err) - return cli.GetErrorBody(config, res) - } - } else { - log.Info("") - log.Infof("Create comment on JIRA issue %s:", jIssue.Key) - log.Infof(" GitHub Comment ID: %d", ghComment.GetID()) - log.Infof(" GitHub user login: %s", ghComment.User.GetLogin()) - log.Infof(" Github user name: %s", ghComment.User.GetName()) - log.Infof(" Created date: %s", ghComment.GetCreatedAt().Format(commentDateFormat)) - if ghComment.GetBody() == "" { - log.Info(" Body: empty") - } else { - body := newlineReplaceRegex.ReplaceAllString(ghComment.GetBody(), "\\n") - if len(body) <= 20 { - log.Infof(" Body: %s", body) - } else { - log.Infof(" Body: %s...", body[0:20]) - } - } - log.Info("") - } + log.Debug("Updated JIRA comment %s.", comment.ID) return nil } diff --git a/lib/issues.go b/lib/issues.go index 365392e..87bb21d 100644 --- a/lib/issues.go +++ b/lib/issues.go @@ -1,79 +1,46 @@ package lib import ( - "context" - "errors" "fmt" - "regexp" "strings" "time" "github.com/andygrunwald/go-jira" "github.com/coreos/issue-sync/cfg" - "github.com/coreos/issue-sync/cli" + "github.com/coreos/issue-sync/lib/clients" "github.com/google/go-github/github" ) // dateFormat is the format used for the Last IS Update field const dateFormat = "2006-01-02T15:04:05-0700" -// commentDateFormat is the format used in the headers of JIRA comments -const commentDateFormat = "15:04 PM, January 2 2006" - // CompareIssues gets the list of GitHub issues updated since the `since` date, // gets the list of JIRA issues which have GitHub ID custom fields in that list, // then matches each one. If a JIRA issue already exists for a given GitHub issue, // it calls UpdateIssue; if no JIRA issue already exists, it calls CreateIssue. -func CompareIssues(config cfg.Config, ghClient github.Client, jiraClient jira.Client) error { +func CompareIssues(config cfg.Config, ghClient clients.GitHubClient, jiraClient clients.JIRAClient) error { log := config.GetLogger() log.Debug("Collecting issues") - ctx := context.Background() - - user, repo := config.GetRepo() - - i, _, err := cli.MakeGHRequest(config, func() (interface{}, *github.Response, error) { - return ghClient.Issues.ListByRepo(ctx, user, repo, &github.IssueListByRepoOptions{ - Since: config.GetSinceParam(), - State: "all", - ListOptions: github.ListOptions{ - PerPage: 100, - }, - }) - }) + + ghIssues, err := ghClient.ListIssues() if err != nil { return err } - ghIssues, ok := i.([]*github.Issue) - if !ok { - log.Errorf("Get GitHub issues did not return issues! Got: %v", i) - return errors.New(fmt.Sprintf("Get GitHub issues failed: expected []*github.Issue; got %T", i)) - } + if len(ghIssues) == 0 { log.Info("There are no GitHub issues; exiting") return nil } - log.Debug("Collected all GitHub issues") ids := make([]string, len(ghIssues)) for i, v := range ghIssues { ids[i] = fmt.Sprint(*v.ID) } - jql := fmt.Sprintf("project='%s' AND cf[%s] in (%s)", - config.GetProjectKey(), config.GetFieldID(cfg.GitHubID), strings.Join(ids, ",")) - - ji, res, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - return jiraClient.Issue.Search(jql, nil) - }) + jiraIssues, err := jiraClient.ListIssues(strings.Join(ids, ",")) if err != nil { - log.Errorf("Error retrieving JIRA issues: %s", err) - return cli.GetErrorBody(config, res) - } - jiraIssues, ok := ji.([]jira.Issue) - if !ok { - log.Errorf("Get JIRA issues did not return issues! Got: %v", ji) - return errors.New(fmt.Sprintf("Get JIRA issues failed: expected []jira.Issue; got %T", ji)) + return err } log.Debug("Collected all JIRA issues") @@ -100,10 +67,6 @@ func CompareIssues(config cfg.Config, ghClient github.Client, jiraClient jira.Cl return nil } -// newlineReplaceRegex is a regex to match both "\r\n" and just "\n" newline styles, -// in order to allow us to escape both sequences cleanly in the output of a dry run. -var newlineReplaceRegex = regexp.MustCompile("\r?\n") - // DidIssueChange tests each of the relevant fields on the provided JIRA and GitHub issue // and returns whether or not they differ. func DidIssueChange(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue) bool { @@ -147,11 +110,13 @@ func DidIssueChange(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue) // UpdateIssue compares each field of a GitHub issue to a JIRA issue; if any of them // differ, the differing fields of the JIRA issue are updated to match the GitHub // issue. -func UpdateIssue(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { +func UpdateIssue(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghClient clients.GitHubClient, jClient clients.JIRAClient) error { log := config.GetLogger() log.Debugf("Updating JIRA %s with GitHub #%d", jIssue.Key, *ghIssue.Number) + var issue jira.Issue + if DidIssueChange(config, ghIssue, jIssue) { fields := jira.IssueFields{} fields.Unknowns = map[string]interface{}{} @@ -171,76 +136,27 @@ func UpdateIssue(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghC fields.Type = jIssue.Fields.Type - issue := &jira.Issue{ + issue = jira.Issue{ Fields: &fields, Key: jIssue.Key, ID: jIssue.ID, } - if !config.IsDryRun() { - _, res, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - return jClient.Issue.Update(issue) - }) - - if err != nil { - log.Errorf("Error updating JIRA issue %s: %v", jIssue.Key, err) - return cli.GetErrorBody(config, res) - } - } else { - log.Info("") - log.Infof("Update JIRA issue %s with GitHub issue #%d:", jIssue.Key, ghIssue.GetNumber()) - if fields.Summary != jIssue.Fields.Summary { - log.Infof(" Summary: %s", fields.Summary) - } - if fields.Description != "" { - fields.Description = newlineReplaceRegex.ReplaceAllString(fields.Description, "\\n") - if len(fields.Description) > 20 { - log.Infof(" Description: %s...", fields.Description[0:20]) - } else { - log.Infof(" Description: %s", fields.Description) - } - } - key := config.GetFieldKey(cfg.GitHubLabels) - if labels, err := fields.Unknowns.String(key); err == nil { - log.Infof(" Labels: %s", labels) - } - key = config.GetFieldKey(cfg.GitHubStatus) - if state, err := fields.Unknowns.String(key); err == nil { - log.Infof(" State: %s", state) - } - log.Info("") + var err error + issue, err = jClient.UpdateIssue(issue) + if err != nil { + return err } log.Debugf("Successfully updated JIRA issue %s!", jIssue.Key) } else { + issue = jIssue log.Debugf("JIRA issue %s is already up to date!", jIssue.Key) } - i, _, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - return jClient.Issue.Get(jIssue.ID, nil) - }) - if err != nil { - log.Errorf("Error retrieving JIRA issue %s to get comments.", jIssue.Key) - } - issue, ok := i.(*jira.Issue) - if !ok { - log.Errorf("Get JIRA issue did not return issue! Got: %v", i) - return errors.New(fmt.Sprintf("Get JIRA issue failed: expected *jira.Issue; got %T", i)) - } - var comments []jira.Comment - if issue.Fields.Comments == nil { - log.Debugf("JIRA issue %s has no comments.", jIssue.Key) - } else { - commentPtrs := issue.Fields.Comments.Comments - comments = make([]jira.Comment, len(commentPtrs)) - for i, v := range commentPtrs { - comments[i] = *v - } - log.Debugf("JIRA issue %s has %d comments", jIssue.Key, len(comments)) - } - if err = CompareComments(config, ghIssue, jIssue, comments, ghClient, jClient); err != nil { + if err := CompareComments(config, ghIssue, issue, ghClient, jClient); err != nil { return err } @@ -249,7 +165,7 @@ func UpdateIssue(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghC // CreateIssue generates a JIRA issue from the various fields on the given GitHub issue, then // sends it to the JIRA API. -func CreateIssue(config cfg.Config, issue github.Issue, ghClient github.Client, jClient jira.Client) error { +func CreateIssue(config cfg.Config, issue github.Issue, ghClient clients.GitHubClient, jClient clients.JIRAClient) error { log := config.GetLogger() log.Debugf("Creating JIRA issue based on GitHub issue #%d", *issue.Number) @@ -259,72 +175,36 @@ func CreateIssue(config cfg.Config, issue github.Issue, ghClient github.Client, Name: "Task", // TODO: Determine issue type }, Project: config.GetProject(), - Summary: *issue.Title, - Description: *issue.Body, + Summary: issue.GetTitle(), + Description: issue.GetBody(), Unknowns: map[string]interface{}{}, } - key := config.GetFieldKey(cfg.GitHubID) - fields.Unknowns[key] = *issue.ID - key = config.GetFieldKey(cfg.GitHubNumber) - fields.Unknowns[key] = *issue.Number - key = config.GetFieldKey(cfg.GitHubStatus) - fields.Unknowns[key] = *issue.State - key = config.GetFieldKey(cfg.GitHubReporter) - fields.Unknowns[key] = issue.User.GetLogin() - key = config.GetFieldKey(cfg.GitHubLabels) + fields.Unknowns[config.GetFieldKey(cfg.GitHubID)] = issue.GetID() + fields.Unknowns[config.GetFieldKey(cfg.GitHubNumber)] = issue.GetNumber() + fields.Unknowns[config.GetFieldKey(cfg.GitHubStatus)] = issue.GetState() + fields.Unknowns[config.GetFieldKey(cfg.GitHubReporter)] = issue.User.GetLogin() + strs := make([]string, len(issue.Labels)) for i, v := range issue.Labels { strs[i] = *v.Name } - fields.Unknowns[key] = strings.Join(strs, ",") - key = config.GetFieldKey(cfg.LastISUpdate) - fields.Unknowns[key] = time.Now().Format(dateFormat) + fields.Unknowns[config.GetFieldKey(cfg.GitHubLabels)] = strings.Join(strs, ",") + + fields.Unknowns[config.GetFieldKey(cfg.LastISUpdate)] = time.Now().Format(dateFormat) - jIssue := &jira.Issue{ + jIssue := jira.Issue{ Fields: &fields, } - if !config.IsDryRun() { - i, res, err := cli.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { - return jClient.Issue.Create(jIssue) - }) - if err != nil { - log.Errorf("Error creating JIRA issue: %v", err) - return cli.GetErrorBody(config, res) - } - var ok bool - jIssue, ok = i.(*jira.Issue) - if !ok { - log.Errorf("Create JIRA issue did not return issue! Got: %v", i) - return errors.New(fmt.Sprintf("Create JIRA issue failed: expected *jira.Issue; got %T", i)) - } - } else { - log.Info("") - log.Infof("Create JIRA issue for GitHub issue #%d:", issue.GetNumber()) - log.Infof(" Summary: %s", fields.Summary) - if fields.Description == "" { - log.Infof(" Description: empty") - } else { - fields.Description = newlineReplaceRegex.ReplaceAllString(fields.Description, "\\n") - if len(fields.Description) <= 20 { - log.Infof(" Description: %s", fields.Description) - } else { - log.Infof(" Description: %s...", fields.Description[0:20]) - } - } - key := config.GetFieldKey(cfg.GitHubLabels) - log.Infof(" Labels: %s", fields.Unknowns[key]) - key = config.GetFieldKey(cfg.GitHubStatus) - log.Infof(" State: %s", fields.Unknowns[key]) - key = config.GetFieldKey(cfg.GitHubReporter) - log.Infof(" Reporter: %s", fields.Unknowns[key]) - log.Info("") + jIssue, err := jClient.CreateIssue(jIssue) + if err != nil { + return err } log.Debugf("Created JIRA issue %s!", jIssue.Key) - if err := CompareComments(config, issue, *jIssue, nil, ghClient, jClient); err != nil { + if err := CompareComments(config, issue, jIssue, ghClient, jClient); err != nil { return err }