diff --git a/cmd/root.go b/cmd/root.go index aad7cf2..c812225 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,62 +2,17 @@ package cmd import ( "context" - "encoding/json" - "errors" "fmt" - "io/ioutil" - "net/url" - "os" "regexp" "strconv" "strings" - "syscall" "time" "github.com/Sirupsen/logrus" "github.com/andygrunwald/go-jira" - "github.com/cenkalti/backoff" - "github.com/fsnotify/fsnotify" + "github.com/coreos/issue-sync/lib" "github.com/google/go-github/github" "github.com/spf13/cobra" - "github.com/spf13/viper" - "golang.org/x/crypto/ssh/terminal" - "golang.org/x/oauth2" -) - -var ( - // log is a globally accessibly logrus logger. - log *logrus.Entry - // defaultLogLevel is the level logrus should default to if the configured option can't be parsed. - defaultLogLevel = logrus.InfoLevel - // rootCmdFile is the file viper loads the configuration from (default $HOME/.issue-sync.json). - rootCmdFile string - // rootCmdCfg is the configuration object; it merges command line options, config files, and defaults. - rootCmdCfg *viper.Viper - - // since is the time we use to filter issues. Only GitHub issues updated after the `since` date - // will be requsted. - since time.Time - // ghIDFieldID is the customfield ID of the GitHub ID field in JIRA. - ghIDFieldID string - // ghNumFieldID is the customfield ID of the GitHub Number field in JIRA. - ghNumFieldID string - // ghlabelsFieldID is the customfield ID of the GitHub Labels field in JIRA. - ghLabelsFieldID string - // ghStatusFieldID is the customfield ID of the GitHub Status field in JIRA. - ghStatusFieldID string - // ghReporterFieldID is the customfield ID of the GitHub Reporter field in JIRA. - ghReporterFieldID string - // isLastUpdateFieldID is the customfield ID of the Last Issue-Sync Update field in JIRA. - isLastUpdateFieldID string - - // project is the JIRA project set on the command line; it is a JIRA API object - // from which we can retrieve any data. - project jira.Project - - // dryRun configures whether the application calls the create/update endpoints of the JIRA - // API or just prints out the actions it would take. - dryRun bool ) // dateFormat is the format used for the `Last Issue-Sync Update` field. @@ -68,347 +23,64 @@ const commentDateFormat = "15:04 PM, January 2 2006" // Execute provides a single function to run the root command and handle errors. func Execute() { + // Create a temporary logger that we can use if an error occurs before the real one is instantiated. + log := logrus.New() if err := RootCmd.Execute(); err != nil { log.Fatal(err) } } -// 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(res *jira.Response) error { - 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 - } - - 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(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 = rootCmdCfg.GetDuration("timeout") - - 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(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 = rootCmdCfg.GetDuration("timeout") - - er := backoff.Retry(op, b) - if er != nil { - return nil, nil, er - } - - return ret, res, err -} - -// getGitHubClient initializes a GitHub API client with an OAuth client for authentication, -// then makes an API request to confirm that the service is running and the auth token -// is valid. -func getGitHubClient(token string) (*github.Client, error) { - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: 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(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 client, then sets the Basic Auth credentials -// passed to it. (OAuth token support is planned.) It then requests the project using -// the key provided on the command line to have it accessible by future functions and -// to check that the API is accessible and the auth credentials are valid. -func getJIRAClient(username, password, baseURL string) (*jira.Client, error) { - client, err := jira.NewClient(nil, baseURL) - if err != nil { - log.Errorf("Error initializing JIRA client; check your base URI. Error: %v", err) - return nil, err - } - client.Authentication.SetBasicAuth(username, password) - - log.Debug("JIRA client initialized; getting project") - - proj, resp, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { - return client.Project.Get(rootCmdCfg.GetString("jira-project")) - }) - if err != nil { - log.Errorf("Unknown error using JIRA client. Error: %v", err) - return nil, err - } else if resp.StatusCode == 404 { - log.Errorf("Error retrieving JIRA project; check your key. Error: %v", err) - return nil, jira.CheckResponse(resp.Response) - } else if resp.StatusCode == 401 { - log.Errorf("Error connecting to JIRA; check your credentials. Error: %v", err) - return nil, jira.CheckResponse(resp.Response) - } - - p, ok := proj.(*jira.Project) - if !ok { - log.Errorf("Get JIRA project did not return project! Value: %v", proj) - return nil, fmt.Errorf("Get project failed: expected *jira.Project; got %T", proj) - } - project = *p - - log.Debug("Successfully connected to JIRA.") - return client, nil -} - // RootCmd represents the command itself and configures it. var RootCmd = &cobra.Command{ Use: "issue-sync [options]", Short: "A tool to synchronize GitHub and JIRA issues", Long: "Full docs coming later; see https://github.com/coreos/issue-sync", - PreRun: func(cmd *cobra.Command, args []string) { - rootCmdCfg.BindPFlags(cmd.Flags()) - log = newLogger("issue-sync", rootCmdCfg.GetString("log-level")) - }, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateConfig(); err != nil { + config, err := lib.NewConfig(cmd) + if err != nil { return err } - ghClient, err := getGitHubClient(rootCmdCfg.GetString("github-token")) + ghClient, err := lib.GetGitHubClient(config) if err != nil { return err } - jiraClient, err := getJIRAClient( - rootCmdCfg.GetString("jira-user"), - rootCmdCfg.GetString("jira-pass"), - rootCmdCfg.GetString("jira-uri"), - ) + jiraClient, err := lib.GetJIRAClient(config) if err != nil { return err } - if err := getFieldIDs(*jiraClient); err != nil { + if err := config.LoadJIRAConfig(*jiraClient); err != nil { return err } - if err := compareIssues(*ghClient, *jiraClient); err != nil { + if err := compareIssues(config, *ghClient, *jiraClient); err != nil { return err } - if !dryRun { - return setLastUpdateTime() + if !config.IsDryRun() { + return config.SaveConfig() } return nil }, } -// validateConfig checks the values provided to all of the configuration -// options, ensuring that e.g. `since` is a valid date, `jira-uri` is a -// real URI, etc. This is the first level of checking. It does not confirm -// if a JIRA server is running at `jira-uri` for example; that is checked -// in getJIRAClient when we actually make a call to the API. -func validateConfig() error { - // Log level and config file location are validated already - - log.Debug("Checking config variables...") - token := rootCmdCfg.GetString("github-token") - if token == "" { - return errors.New("GitHub token required") - } - - jUser := rootCmdCfg.GetString("jira-user") - if jUser == "" { - return errors.New("Jira username required") - } - - jPass := rootCmdCfg.GetString("jira-pass") - if jPass == "" { - fmt.Print("Enter your JIRA password: ") - bytePass, err := terminal.ReadPassword(int(syscall.Stdin)) - if err != nil { - return errors.New("Jira password required") - } - rootCmdCfg.Set("jira-pass", string(bytePass)) - } - - repo := rootCmdCfg.GetString("repo-name") - if repo == "" { - return errors.New("GitHub repository required") - } - if !strings.Contains(repo, "/") || len(strings.Split(repo, "/")) != 2 { - return errors.New("GitHub repository must be of form user/repo") - } - - uri := rootCmdCfg.GetString("jira-uri") - if uri == "" { - return errors.New("JIRA URI required") - } - if _, err := url.ParseRequestURI(uri); err != nil { - return errors.New("JIRA URI must be valid URI") - } - - project := rootCmdCfg.GetString("jira-project") - if project == "" { - return errors.New("JIRA project required") - } - - sinceStr := rootCmdCfg.GetString("since") - if sinceStr == "" { - rootCmdCfg.Set("since", "1970-01-01T00:00:00+0000") - } - var err error - since, err = time.Parse(dateFormat, sinceStr) - if err != nil { - return errors.New("Since date must be in ISO-8601 format") - } - log.Debug("All config variables are valid!") - - return nil -} - -// JIRAField represents field metadata in JIRA. For an example of its -// structure, make a request to `${jira-uri}/rest/api/2/field`. -type JIRAField struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Custom bool `json:"custom"` - Orderable bool `json:"orderable"` - Navigable bool `json:"navigable"` - Searchable bool `json:"searchable"` - ClauseNames []string `json:"clauseNames"` - Schema struct { - Type string `json:"type"` - System string `json:"system,omitempty"` - Items string `json:"items,omitempty"` - Custom string `json:"custom,omitempty"` - CustomID int `json:"customId,omitempty"` - } `json:"schema,omitempty"` -} - -// getFieldIDs requests the metadata of every issue field in the JIRA -// project and saves the IDs of the custom fields used by issue-sync. -func getFieldIDs(client jira.Client) error { - log.Debug("Collecting field IDs.") - req, err := client.NewRequest("GET", "/rest/api/2/field", nil) - if err != nil { - return err - } - fields := new([]JIRAField) - - _, _, err = makeJIRARequest(func() (interface{}, *jira.Response, error) { - res, err := client.Do(req, fields) - return nil, res, err - }) - if err != nil { - return err - } - - for _, field := range *fields { - switch field.Name { - case "GitHub ID": - ghIDFieldID = fmt.Sprint(field.Schema.CustomID) - case "GitHub Number": - ghNumFieldID = fmt.Sprint(field.Schema.CustomID) - case "GitHub Labels": - ghLabelsFieldID = fmt.Sprint(field.Schema.CustomID) - case "GitHub Status": - ghStatusFieldID = fmt.Sprint(field.Schema.CustomID) - case "GitHub Reporter": - ghReporterFieldID = fmt.Sprint(field.Schema.CustomID) - case "Last Issue-Sync Update": - isLastUpdateFieldID = fmt.Sprint(field.Schema.CustomID) - } - } - - if ghIDFieldID == "" { - return errors.New("could not find ID of 'GitHub ID' custom field. Check that it is named correctly.") - } else if ghNumFieldID == "" { - return errors.New("could not find ID of 'GitHub Number' custom field. Check that it is named correctly.") - } else if ghLabelsFieldID == "" { - return errors.New("could not find ID of 'Github Labels' custom field. Check that it is named correctly.") - } else if ghStatusFieldID == "" { - return errors.New("could not find ID of 'Github Status' custom field. Check that it is named correctly.") - } else if ghReporterFieldID == "" { - return errors.New("could not find ID of 'Github Reporter' custom field. Check that it is named correctly.") - } else if isLastUpdateFieldID == "" { - return errors.New("could not find ID of 'Last Issue-Sync Update' custom field. Check that it is named correctly.") - } - - log.Debug("All fields have been checked.") - - return nil -} - // 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 updates the issue; if no JIRA issue already exists, it creates one. -func compareIssues(ghClient github.Client, jiraClient jira.Client) error { +func compareIssues(config lib.Config, ghClient github.Client, jiraClient jira.Client) error { + log := config.GetLogger() + log.Debug("Collecting issues") ctx := context.Background() - repo := strings.Split(rootCmdCfg.GetString("repo-name"), "/") + user, repo := config.GetRepo() - i, _, err := makeGHRequest(func() (interface{}, *github.Response, error) { - return ghClient.Issues.ListByRepo(ctx, repo[0], repo[1], &github.IssueListByRepoOptions{ - Since: since, + i, _, err := lib.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, @@ -435,14 +107,14 @@ func compareIssues(ghClient github.Client, jiraClient jira.Client) error { } jql := fmt.Sprintf("project='%s' AND cf[%s] in (%s)", - rootCmdCfg.GetString("jira-project"), ghIDFieldID, strings.Join(ids, ",")) + config.GetProjectKey(), config.GetFieldID(lib.GitHubID), strings.Join(ids, ",")) - ji, res, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + ji, res, err := lib.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { return jiraClient.Issue.Search(jql, nil) }) if err != nil { log.Errorf("Error retrieving JIRA issues: %v", err) - return getErrorBody(res) + return lib.GetErrorBody(config, res) } jiraIssues, ok := ji.([]jira.Issue) if !ok { @@ -455,17 +127,17 @@ func compareIssues(ghClient github.Client, jiraClient jira.Client) error { for _, ghIssue := range ghIssues { found := false for _, jIssue := range jiraIssues { - id, _ := jIssue.Fields.Unknowns.Int(fmt.Sprintf("customfield_%s", ghIDFieldID)) - if int64(*ghIssue.ID) == id { + id, err := jIssue.Fields.Unknowns.Int(config.GetFieldKey(lib.GitHubID)) + if err == nil && int64(*ghIssue.ID) == id { found = true - if err := updateIssue(*ghIssue, jIssue, ghClient, jiraClient); err != nil { + if err := updateIssue(config, *ghIssue, jIssue, ghClient, jiraClient); err != nil { log.Errorf("Error updating issue %s. Error: %v", jIssue.Key, err) } break } } if !found { - if err := createIssue(*ghIssue, ghClient, jiraClient); err != nil { + if err := createIssue(config, *ghIssue, ghClient, jiraClient); err != nil { log.Errorf("Error creating issue for #%d. Error: %v", *ghIssue.Number, err) } } @@ -481,7 +153,9 @@ var newlineReplaceRegex = regexp.MustCompile("\r?\n") // 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(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { +func updateIssue(config lib.Config, ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { + log := config.GetLogger() + log.Debugf("Updating JIRA %s with GitHub #%d", jIssue.Key, *ghIssue.Number) anyDifferent := false @@ -499,14 +173,14 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client fields.Description = *ghIssue.Body } - key := fmt.Sprintf("customfield_%s", ghStatusFieldID) + key := config.GetFieldKey(lib.GitHubStatus) field, err := jIssue.Fields.Unknowns.String(key) if err != nil || *ghIssue.State != field { anyDifferent = true fields.Unknowns[key] = *ghIssue.State } - key = fmt.Sprintf("customfield_%s", ghReporterFieldID) + key = config.GetFieldKey(lib.GitHubReporter) field, err = jIssue.Fields.Unknowns.String(key) if err != nil || *ghIssue.User.Login != field { anyDifferent = true @@ -518,7 +192,7 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client labels[i] = *l.Name } - key = fmt.Sprintf("customfield_%s", ghLabelsFieldID) + key = config.GetFieldKey(lib.GitHubLabels) field, err = jIssue.Fields.Unknowns.String(key) if err != nil && strings.Join(labels, ",") != field { anyDifferent = true @@ -526,7 +200,7 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client } if anyDifferent { - key = fmt.Sprintf("customfield_%s", isLastUpdateFieldID) + key = config.GetFieldKey(lib.LastISUpdate) fields.Unknowns[key] = time.Now().Format(dateFormat) fields.Type = jIssue.Fields.Type @@ -540,14 +214,14 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client ID: jIssue.ID, } - if !dryRun { - _, res, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + if !config.IsDryRun() { + _, res, err := lib.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 getErrorBody(res) + return lib.GetErrorBody(config, res) } } else { log.Info("") @@ -563,11 +237,11 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client log.Infof(" Description: %s", fields.Description) } } - key := fmt.Sprintf("customfield_%s", ghLabelsFieldID) + key := config.GetFieldKey(lib.GitHubLabels) if labels, err := fields.Unknowns.String(key); err == nil { log.Infof(" Labels: %s", labels) } - key = fmt.Sprintf("customfield_%s", ghStatusFieldID) + key = config.GetFieldKey(lib.GitHubStatus) if state, err := fields.Unknowns.String(key); err == nil { log.Infof(" State: %s", state) } @@ -579,7 +253,7 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client log.Debugf("JIRA issue %s is already up to date!", jIssue.Key) } - i, _, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + i, _, err := lib.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { return jClient.Issue.Get(jIssue.ID, nil) }) if err != nil { @@ -603,7 +277,7 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client log.Debugf("JIRA issue %s has %d comments", jIssue.Key, len(comments)) } - if err = createComments(ghIssue, jIssue, comments, ghClient, jClient); err != nil { + if err = createComments(config, ghIssue, jIssue, comments, ghClient, jClient); err != nil { return err } @@ -612,47 +286,49 @@ func updateIssue(ghIssue github.Issue, jIssue jira.Issue, ghClient github.Client // createIssue generates a JIRA issue from the various fields on the given GitHub issue then // sends it to the JIRA API. -func createIssue(issue github.Issue, ghClient github.Client, jClient jira.Client) error { +func createIssue(config lib.Config, issue github.Issue, ghClient github.Client, jClient jira.Client) error { + log := config.GetLogger() + log.Debugf("Creating JIRA issue based on GitHub issue #%d", *issue.Number) fields := jira.IssueFields{ Type: jira.IssueType{ Name: "Task", // TODO: Determine issue type }, - Project: project, + Project: config.GetProject(), Summary: *issue.Title, Description: *issue.Body, Unknowns: map[string]interface{}{}, } - key := fmt.Sprintf("customfield_%s", ghIDFieldID) + key := config.GetFieldKey(lib.GitHubID) fields.Unknowns[key] = *issue.ID - key = fmt.Sprintf("customfield_%s", ghNumFieldID) + key = config.GetFieldKey(lib.GitHubNumber) fields.Unknowns[key] = *issue.Number - key = fmt.Sprintf("customfield_%s", ghStatusFieldID) + key = config.GetFieldKey(lib.GitHubStatus) fields.Unknowns[key] = *issue.State - key = fmt.Sprintf("customfield_%s", ghReporterFieldID) + key = config.GetFieldKey(lib.GitHubReporter) fields.Unknowns[key] = issue.User.GetLogin() - key = fmt.Sprintf("customfield_%s", ghLabelsFieldID) + key = config.GetFieldKey(lib.GitHubLabels) strs := make([]string, len(issue.Labels)) for i, v := range issue.Labels { strs[i] = *v.Name } fields.Unknowns[key] = strings.Join(strs, ",") - key = fmt.Sprintf("customfield_%s", isLastUpdateFieldID) + key = config.GetFieldKey(lib.LastISUpdate) fields.Unknowns[key] = time.Now().Format(dateFormat) jIssue := &jira.Issue{ Fields: &fields, } - if !dryRun { - i, res, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + if !config.IsDryRun() { + i, res, err := lib.MakeJIRARequest(config, func() (interface{}, *jira.Response, error) { return jClient.Issue.Create(jIssue) }) if err != nil { log.Errorf("Error creating JIRA issue: %v", err) - return getErrorBody(res) + return lib.GetErrorBody(config, res) } var ok bool jIssue, ok = i.(*jira.Issue) @@ -674,18 +350,18 @@ func createIssue(issue github.Issue, ghClient github.Client, jClient jira.Client log.Infof(" Description: %s...", fields.Description[0:20]) } } - key := fmt.Sprintf("customfield_%s", ghLabelsFieldID) + key := config.GetFieldKey(lib.GitHubLabels) log.Infof(" Labels: %s", fields.Unknowns[key]) - key = fmt.Sprintf("customfield_%s", ghStatusFieldID) + key = config.GetFieldKey(lib.GitHubStatus) log.Infof(" State: %s", fields.Unknowns[key]) - key = fmt.Sprintf("customfield_%s", ghReporterFieldID) + key = config.GetFieldKey(lib.GitHubReporter) log.Infof(" Reporter: %s", fields.Unknowns[key]) log.Info("") } log.Debugf("Created JIRA issue %s!", jIssue.Key) - if err := createComments(issue, *jIssue, nil, ghClient, jClient); err != nil { + if err := createComments(config, issue, *jIssue, nil, ghClient, jClient); err != nil { return err } @@ -705,16 +381,18 @@ var jCommentIDRegex = regexp.MustCompile("^Comment \\(ID (\\d+)\\)") // createCommments 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 createComments(ghIssue github.Issue, jIssue jira.Issue, existing []jira.Comment, ghClient github.Client, jClient jira.Client) error { +func createComments(config lib.Config, ghIssue github.Issue, jIssue jira.Issue, existing []jira.Comment, ghClient github.Client, jClient jira.Client) error { + log := config.GetLogger() + if *ghIssue.Comments == 0 { log.Debugf("Issue #%d has no comments, skipping.", *ghIssue.Number) return nil } ctx := context.Background() - repo := strings.Split(rootCmdCfg.GetString("repo-name"), "/") - c, _, err := makeGHRequest(func() (interface{}, *github.Response, error) { - return ghClient.Issues.ListComments(ctx, repo[0], repo[1], *ghIssue.Number, &github.IssueListCommentsOptions{ + user, repo := config.GetRepo() + c, _, err := lib.MakeGHRequest(config, func() (interface{}, *github.Response, error) { + return ghClient.Issues.ListComments(ctx, user, repo, *ghIssue.Number, &github.IssueListCommentsOptions{ Sort: "created", Direction: "asc", }) @@ -743,14 +421,14 @@ func createComments(ghIssue github.Issue, jIssue jira.Issue, existing []jira.Com } found = true - updateComment(*ghComment, jComment, jIssue, ghClient, jClient) + updateComment(config, *ghComment, jComment, jIssue, ghClient, jClient) break } if found { continue } - if err := createComment(*ghComment, jIssue, ghClient, jClient); err != nil { + if err := createComment(config, *ghComment, jIssue, ghClient, jClient); err != nil { return err } } @@ -761,7 +439,9 @@ func createComments(ghIssue github.Issue, jIssue jira.Issue, existing []jira.Com // 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(ghComment github.IssueComment, jComment jira.Comment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { +func updateComment(config lib.Config, ghComment github.IssueComment, jComment jira.Comment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) 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) @@ -770,7 +450,7 @@ func updateComment(ghComment github.IssueComment, jComment jira.Comment, jIssue return nil } - u, _, err := makeGHRequest(func() (interface{}, *github.Response, error) { + u, _, err := lib.MakeGHRequest(config, func() (interface{}, *github.Response, error) { return ghClient.Users.Get(context.Background(), *ghComment.User.Login) }) if err != nil { @@ -802,20 +482,20 @@ func updateComment(ghComment github.IssueComment, jComment jira.Comment, jIssue Body: body, } - if !dryRun { + 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: %v", err) return err } - _, res, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + _, res, err := lib.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 getErrorBody(res) + return lib.GetErrorBody(config, res) } } else { log.Info("") @@ -839,8 +519,10 @@ func updateComment(ghComment github.IssueComment, jComment jira.Comment, jIssue // 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(ghComment github.IssueComment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { - u, _, err := makeGHRequest(func() (interface{}, *github.Response, error) { +func createComment(config lib.Config, ghComment github.IssueComment, jIssue jira.Issue, ghClient github.Client, jClient jira.Client) error { + log := config.GetLogger() + + u, _, err := lib.MakeGHRequest(config, func() (interface{}, *github.Response, error) { return ghClient.Users.Get(context.Background(), *ghComment.User.Login) }) if err != nil { @@ -867,13 +549,13 @@ func createComment(ghComment github.IssueComment, jIssue jira.Issue, ghClient gi Body: body, } - if !dryRun { - _, res, err := makeJIRARequest(func() (interface{}, *jira.Response, error) { + if !config.IsDryRun() { + _, res, err := lib.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 getErrorBody(res) + return lib.GetErrorBody(config, res) } } else { log.Info("") @@ -898,50 +580,9 @@ func createComment(ghComment github.IssueComment, jIssue jira.Issue, ghClient gi return nil } -// Config represents the structure of the JSON configuration file used by Viper. -type Config struct { - LogLevel string `json:"log-level" mapstructure:"log-level"` - GithubToken string `json:"github-token" mapstructure:"github-token"` - JiraUser string `json:"jira-user" mapstructure:"jira-user"` - RepoName string `json:"repo-name" mapstructure:"repo-name"` - JiraURI string `json:"jira-uri" mapstructure:"jira-uri"` - JiraProject string `json:"jira-project" mapstructure:"jira-project"` - Since string `json:"since" mapstructure:"since"` - Timeout time.Duration `json:"timeout" mapstructure:"timeout"` -} - -// setLastUpdateTime sets the `since` date of the current configuration to the -// present date, then serializes the configuration into JSON and saves it to -// the currently used configuration file (default $HOME/.issue-sync.json) -func setLastUpdateTime() error { - rootCmdCfg.Set("since", time.Now().Format(dateFormat)) - - var c Config - rootCmdCfg.Unmarshal(&c) - - b, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err - } - - f, err := os.OpenFile(rootCmdCfg.ConfigFileUsed(), os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0644) - if err != nil { - return err - } - defer f.Close() - - f.WriteString(string(b)) - - return nil -} - func init() { - log = logrus.NewEntry(logrus.New()) - cobra.OnInitialize(func() { - rootCmdCfg = newViper("issue-sync", rootCmdFile) - }) RootCmd.PersistentFlags().String("log-level", logrus.InfoLevel.String(), "Set the global log level") - RootCmd.PersistentFlags().StringVar(&rootCmdFile, "config", "", "Config file (default is $HOME/.issue-sync.yaml)") + RootCmd.PersistentFlags().String("config", "", "Config file (default is $HOME/.issue-sync.json)") RootCmd.PersistentFlags().StringP("github-token", "t", "", "Set the API Token used to access the GitHub repo") RootCmd.PersistentFlags().StringP("jira-user", "u", "", "Set the JIRA username to authenticate with") RootCmd.PersistentFlags().StringP("jira-pass", "p", "", "Set the JIRA password to authenticate with") @@ -949,72 +590,6 @@ func init() { RootCmd.PersistentFlags().StringP("jira-uri", "U", "", "Set the base uri of the JIRA instance") RootCmd.PersistentFlags().StringP("jira-project", "P", "", "Set the key of the JIRA project") RootCmd.PersistentFlags().StringP("since", "s", "1970-01-01T00:00:00+0000", "Set the day that the update should run forward from") - RootCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "d", false, "Print out actions to be taken, but do not execute them") + RootCmd.PersistentFlags().BoolP("dry-run", "d", false, "Print out actions to be taken, but do not execute them") RootCmd.PersistentFlags().DurationP("timeout", "T", time.Minute, "Set the maximum timeout on all API calls") } - -// parseLogLevel is a helper function to parse the log level passed in the -// configuration into a logrus Level, or to use the default log level set -// above if the log level can't be parsed. -func parseLogLevel(level string) logrus.Level { - if level == "" { - return defaultLogLevel - } - - ll, err := logrus.ParseLevel(level) - if err != nil { - fmt.Printf("Failed to parse log level, using default. Error: %v\n", err) - return defaultLogLevel - } - return ll -} - -// newViper generates a viper configuration object which -// merges (in order from highest to lowest priority) the -// command line options, configuration file options, and -// default configuration values. This viper object becomes -// the single source of truth for the app configuration. -func newViper(appName, cfgFile string) *viper.Viper { - v := viper.New() - - v.SetEnvPrefix(appName) - v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - v.AutomaticEnv() - - v.SetConfigName(fmt.Sprintf("config-%s", appName)) - v.AddConfigPath(".") - if cfgFile != "" { - v.SetConfigFile(cfgFile) - } - - if err := v.ReadInConfig(); err == nil { - log.WithField("file", v.ConfigFileUsed()).Infof("config file loaded") - v.WatchConfig() - v.OnConfigChange(func(e fsnotify.Event) { - log.WithField("file", e.Name).Info("config file changed") - }) - } else { - if cfgFile != "" { - log.WithError(err).Warningf("Error reading config file: %v", cfgFile) - } - } - - if log.Level == logrus.DebugLevel { - v.Debug() - } - - return v -} - -// newLogger uses the log level provided in the configuration -// to create a new logrus logger and set fields on it to make -// it easy to use. -func newLogger(app, level string) *logrus.Entry { - logger := logrus.New() - logger.Level = parseLogLevel(level) - logEntry := logrus.NewEntry(logger).WithFields(logrus.Fields{ - "app": app, - }) - logEntry.WithField("log-level", logger.Level).Info("log level set") - return logEntry -} diff --git a/lib/clients.go b/lib/clients.go new file mode 100644 index 0000000..7e6ae5c --- /dev/null +++ b/lib/clients.go @@ -0,0 +1,138 @@ +package lib + +import ( + "context" + "errors" + "io/ioutil" + + "github.com/andygrunwald/go-jira" + "github.com/cenkalti/backoff" + "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 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 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 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 client with an OAuth client for authentication, +// then makes an API request to confirm that the service is running and the auth token +// is valid. +func GetGitHubClient(config 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 client, then sets the Basic Auth credentials +// passed to it. (OAuth token support is planned.) +// +// The validity of the client and its authentication are not checked here. One way +// to check them would be to call config.LoadJIRAConfig() after this function. +func GetJIRAClient(config Config) (*jira.Client, error) { + log := config.GetLogger() + + client, err := jira.NewClient(nil, config.GetConfigString("jira-uri")) + if err != nil { + log.Errorf("Error initializing JIRA client; check your base URI. Error: %v", err) + return nil, err + } + + client.Authentication.SetBasicAuth(config.GetConfigString("jira-user"), config.GetConfigString("jira-pass")) + + log.Debug("JIRA client initialized") + return client, nil +} diff --git a/lib/config.go b/lib/config.go new file mode 100644 index 0000000..a08ff09 --- /dev/null +++ b/lib/config.go @@ -0,0 +1,435 @@ +package lib + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strings" + "syscall" + "time" + + "github.com/Sirupsen/logrus" + "github.com/andygrunwald/go-jira" + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/crypto/ssh/terminal" +) + +// dateFormat is the format used for the `since` configuration parameter +const dateFormat = "2006-01-02T15:04:05-0700" + +// defaultLogLevel is the level logrus should default to if the configured option can't be parsed +const defaultLogLevel = logrus.InfoLevel + +// fieldKey is an enum-like type to represent the customfield ID keys +type fieldKey int + +const ( + GitHubID fieldKey = iota + GitHubNumber fieldKey = iota + GitHubLabels fieldKey = iota + GitHubStatus fieldKey = iota + GitHubReporter fieldKey = iota + LastISUpdate fieldKey = iota +) + +// fields represents the custom field IDs of the JIRA custom fields we care about +type fields struct { + githubID string + githubNumber string + githubLabels string + githubReporter string + githubStatus string + lastUpdate string +} + +// Config is the root configuration object the application creates. +type Config struct { + // cmdFile is the file Viper is using for its configuration (default $HOME/.issue-sync.json). + cmdFile string + // cmdConfig is the Viper configuration object created from the command line and config file. + cmdConfig viper.Viper + + // log is a logger set up with the configured log level, app name, etc. + log logrus.Entry + + // fieldIDs is the list of custom fields we pulled from the `fields` JIRA endpoint. + fieldIDs fields + + // project represents the JIRA project the user has requested. + project jira.Project + + // since is the parsed value of the `since` configuration parameter, which is the earliest that + // a GitHub issue can have been updated to be retrieved. + since time.Time +} + +// NewConfig creates a new, immutable configuration object. This object +// holds the Viper configuration and the logger, and is validated. The +// JIRA configuration is not yet initialized. +func NewConfig(cmd *cobra.Command) (Config, error) { + config := Config{} + + var err error + config.cmdFile, err = cmd.Flags().GetString("config") + if err != nil { + config.cmdFile = "" + } + + config.cmdConfig = *newViper("issue-sync", config.cmdFile) + config.cmdConfig.BindPFlags(cmd.Flags()) + + config.cmdFile = config.cmdConfig.ConfigFileUsed() + + config.log = *newLogger("issue-sync", config.cmdConfig.GetString("log-level")) + + if err := config.validateConfig(); err != nil { + return Config{}, err + } + + return config, nil +} + +// LoadJIRAConfig loads the JIRA configuration (project key, +// custom field IDs) from a remote JIRA server. +func (c *Config) LoadJIRAConfig(client jira.Client) error { + proj, res, err := MakeJIRARequest(*c, func() (interface{}, *jira.Response, error) { + return client.Project.Get(c.cmdConfig.GetString("jira-project")) + }) + if err != nil { + c.log.Errorf("Error retrieving JIRA project; check key and credentials. Error: %v", err) + return GetErrorBody(*c, res) + } + if _, ok := proj.(*jira.Project); !ok { + c.log.Errorf("Get JIRA project did not return project! Value: %v", proj) + return errors.New(fmt.Sprintf("Get project failed; expected *jira.Project; got %T", proj)) + } + c.project = *(proj.(*jira.Project)) + + c.fieldIDs, err = c.getFieldIDs(client) + if err != nil { + return err + } + + return nil +} + +// GetConfigFile returns the file that Viper loaded the configuration from. +func (c Config) GetConfigFile() string { + return c.cmdFile +} + +// GetConfigString returns a string value from the Viper configuration. +func (c Config) GetConfigString(key string) string { + return c.cmdConfig.GetString(key) +} + +// GetSinceParam returns the `since` configuration parameter, parsed as a time.Time. +func (c Config) GetSinceParam() time.Time { + return c.since +} + +// GetLogger returns the configured application logger. +func (c Config) GetLogger() logrus.Entry { + return c.log +} + +// IsDryRun returns whether the application is running in dry-run mode or not. +func (c Config) IsDryRun() bool { + return c.cmdConfig.GetBool("dry-run") +} + +// GetTimeout returns the configured timeout on all API calls, parsed as a time.Duration. +func (c Config) GetTimeout() time.Duration { + return c.cmdConfig.GetDuration("timeout") +} + +// GetFieldID returns the customfield ID of a JIRA custom field. +func (c Config) GetFieldID(key fieldKey) string { + switch key { + case GitHubID: + return c.fieldIDs.githubID + case GitHubNumber: + return c.fieldIDs.githubNumber + case GitHubLabels: + return c.fieldIDs.githubLabels + case GitHubReporter: + return c.fieldIDs.githubReporter + case GitHubStatus: + return c.fieldIDs.githubStatus + case LastISUpdate: + return c.fieldIDs.lastUpdate + default: + return "" + } +} + +// GetFieldKey returns customfield_XXXXX, where XXXXX is the custom field ID (see GetFieldID). +func (c Config) GetFieldKey(key fieldKey) string { + return fmt.Sprintf("customfield_%s", c.GetFieldID(key)) +} + +// GetProject returns the JIRA project the user has configured. +func (c Config) GetProject() jira.Project { + return c.project +} + +// GetProjectKey returns the JIRA key of the configured project. +func (c Config) GetProjectKey() string { + return c.project.Key +} + +// GetRepo returns the user/org name and the repo name of the configured GitHub repository. +func (c Config) GetRepo() (string, string) { + fullName := c.cmdConfig.GetString("repo-name") + parts := strings.Split(fullName, "/") + // We check that repo-name is two parts separated by a slash in NewConfig, so this is safe + return parts[0], parts[1] +} + +// configFile is a serializable representation of the current Viper configuration. +type configFile struct { + LogLevel string `json:"log-level" mapstructure:"log-level"` + GithubToken string `json:"github-token" mapstructure:"github-token"` + JiraUser string `json:"jira-user" mapstructure:"jira-user"` + RepoName string `json:"repo-name" mapstructure:"repo-name"` + JiraUri string `json:"jira-uri" mapstructure:"jira-uri"` + JiraProject string `json:"jira-project" mapstructure:"jira-project"` + Since string `json:"since" mapstructure:"since"` + Timeout time.Duration `json:"timeout" mapstructure:"timeout"` +} + +// SaveConfig updates the `since` parameter to now, then saves the configuration file. +func (c Config) SaveConfig() error { + c.cmdConfig.Set("since", time.Now().Format(dateFormat)) + + var cf configFile + c.cmdConfig.Unmarshal(&cf) + + b, err := json.MarshalIndent(cf, "", " ") + if err != nil { + return err + } + + f, err := os.OpenFile(c.cmdConfig.ConfigFileUsed(), os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0644) + if err != nil { + return err + } + defer f.Close() + + f.WriteString(string(b)) + + return nil +} + +// newViper generates a viper configuration object which +// merges (in order from highest to lowest priority) the +// command line options, configuration file options, and +// default configuration values. This viper object becomes +// the single source of truth for the app configuration. +func newViper(appName, cfgFile string) *viper.Viper { + log := logrus.New() + v := viper.New() + + v.SetEnvPrefix(appName) + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + v.AutomaticEnv() + + v.SetConfigName(fmt.Sprintf("config-%s", appName)) + v.AddConfigPath(".") + if cfgFile != "" { + v.SetConfigFile(cfgFile) + } + + if err := v.ReadInConfig(); err == nil { + log.WithField("file", v.ConfigFileUsed()).Infof("config file loaded") + v.WatchConfig() + v.OnConfigChange(func(e fsnotify.Event) { + log.WithField("file", e.Name).Info("config file changed") + }) + } else { + if cfgFile != "" { + log.WithError(err).Warningf("Error reading config file: %v", cfgFile) + } + } + + if log.Level == logrus.DebugLevel { + v.Debug() + } + + return v +} + +// parseLogLevel is a helper function to parse the log level passed in the +// configuration into a logrus Level, or to use the default log level set +// above if the log level can't be parsed. +func parseLogLevel(level string) logrus.Level { + if level == "" { + return defaultLogLevel + } + + ll, err := logrus.ParseLevel(level) + if err != nil { + fmt.Printf("Failed to parse log level, using default. Error: %v\n", err) + return defaultLogLevel + } + return ll +} + +// newLogger uses the log level provided in the configuration +// to create a new logrus logger and set fields on it to make +// it easy to use. +func newLogger(app, level string) *logrus.Entry { + logger := logrus.New() + logger.Level = parseLogLevel(level) + logEntry := logrus.NewEntry(logger).WithFields(logrus.Fields{ + "app": app, + }) + logEntry.WithField("log-level", logger.Level).Info("log level set") + return logEntry +} + +// validateConfig checks the values provided to all of the configuration +// options, ensuring that e.g. `since` is a valid date, `jira-uri` is a +// real URI, etc. This is the first level of checking. It does not confirm +// if a JIRA client is running at `jira-uri` for example; that is checked +// in getJIRAClient when we actually make a call to the API. +func (c Config) validateConfig() error { + // Log level and config file location are validated already + + c.log.Debug("Checking config variables...") + token := c.cmdConfig.GetString("github-token") + if token == "" { + return errors.New("GitHub token required") + } + + jUser := c.cmdConfig.GetString("jira-user") + if jUser == "" { + return errors.New("Jira username required") + } + + jPass := c.cmdConfig.GetString("jira-pass") + if jPass == "" { + fmt.Print("Enter your JIRA password: ") + bytePass, err := terminal.ReadPassword(int(syscall.Stdin)) + if err != nil { + return errors.New("Jira password required") + } + c.cmdConfig.Set("jira-pass", string(bytePass)) + } + + repo := c.cmdConfig.GetString("repo-name") + if repo == "" { + return errors.New("GitHub repository required") + } + if !strings.Contains(repo, "/") || len(strings.Split(repo, "/")) != 2 { + return errors.New("GitHub repository must be of form user/repo") + } + + uri := c.cmdConfig.GetString("jira-uri") + if uri == "" { + return errors.New("JIRA URI required") + } + if _, err := url.ParseRequestURI(uri); err != nil { + return errors.New("JIRA URI must be valid URI") + } + + project := c.cmdConfig.GetString("jira-project") + if project == "" { + return errors.New("JIRA project required") + } + + sinceStr := c.cmdConfig.GetString("since") + if sinceStr == "" { + c.cmdConfig.Set("since", "1970-01-01T00:00:00+0000") + } + + since, err := time.Parse(dateFormat, sinceStr) + if err != nil { + return errors.New("Since date must be in ISO-8601 format") + } + c.since = since + + c.log.Debug("All config variables are valid!") + + return nil +} + +// jiraField represents field metadata in JIRA. For an example of its +// structure, make a request to `${jira-uri}/rest/api/2/field`. +type jiraField struct { + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Custom bool `json:"custom"` + Orderable bool `json:"orderable"` + Navigable bool `json:"navigable"` + Searchable bool `json:"searchable"` + ClauseNames []string `json:"clauseNames"` + Schema struct { + Type string `json:"type"` + System string `json:"system,omitempty"` + Items string `json:"items,omitempty"` + Custom string `json:"custom,omitempty"` + CustomID int `json:"customId,omitempty"` + } `json:"schema,omitempty"` +} + +// getFieldIDs requests the metadata of every issue field in the JIRA +// project, and saves the IDs of the custom fields used by issue-sync. +func (c Config) getFieldIDs(client jira.Client) (fields, error) { + c.log.Debug("Collecting field IDs.") + req, err := client.NewRequest("GET", "/rest/api/2/field", nil) + if err != nil { + return fields{}, err + } + jFields := new([]jiraField) + + _, _, err = MakeJIRARequest(c, func() (interface{}, *jira.Response, error) { + res, err := client.Do(req, jFields) + return nil, res, err + }) + if err != nil { + return fields{}, err + } + + fieldIDs := fields{} + + for _, field := range *jFields { + switch field.Name { + case "GitHub ID": + fieldIDs.githubID = fmt.Sprint(field.Schema.CustomID) + case "GitHub Number": + fieldIDs.githubNumber = fmt.Sprint(field.Schema.CustomID) + case "GitHub Labels": + fieldIDs.githubLabels = fmt.Sprint(field.Schema.CustomID) + case "GitHub Status": + fieldIDs.githubStatus = fmt.Sprint(field.Schema.CustomID) + case "GitHub Reporter": + fieldIDs.githubReporter = fmt.Sprint(field.Schema.CustomID) + case "Last Issue-Sync Update": + fieldIDs.lastUpdate = fmt.Sprint(field.Schema.CustomID) + } + } + + if fieldIDs.githubID == "" { + return fieldIDs, errors.New("Could not find ID of 'GitHub ID' custom field. Check that it is named correctly.") + } else if fieldIDs.githubNumber == "" { + return fieldIDs, errors.New("Could not find ID of 'GitHub Number' custom field. Check that it is named correctly.") + } else if fieldIDs.githubLabels == "" { + return fieldIDs, errors.New("Could not find ID of 'Github Labels' custom field. Check that it is named correctly.") + } else if fieldIDs.githubStatus == "" { + return fieldIDs, errors.New("Could not find ID of 'Github Status' custom field. Check that it is named correctly.") + } else if fieldIDs.githubReporter == "" { + return fieldIDs, errors.New("Could not find ID of 'Github Reporter' custom field. Check that it is named correctly.") + } else if fieldIDs.lastUpdate == "" { + return fieldIDs, errors.New("Could not find ID of 'Last Issue-Sync Update' custom field. Check that it is named correctly.") + } + + c.log.Debug("All fields have been checked.") + + return fieldIDs, nil +}