From e3578100fe434d0f8ebd1ef85c87ec4e243896a8 Mon Sep 17 00:00:00 2001 From: Morgan Patch Date: Thu, 6 Jul 2017 14:04:37 -0700 Subject: [PATCH] synchronize command: Add structure to connect and compare At this point, all GitHub issues are retrieved, all of their matching JIRA issues are retrieved, and the two are matched in preparation to either create or update the JIRA issues. --- cmd/root.go | 343 ++++++++++++++++++++++++++++++++++++++++++++++--- cmd/version.go | 6 +- 2 files changed, 327 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index e90038b..51e516a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,61 +1,364 @@ package cmd import ( - "strings" - - "os" - + "context" + "encoding/json" + "errors" "fmt" + "net/url" + "os" + "strings" + "time" "github.com/Sirupsen/logrus" + "github.com/andygrunwald/go-jira" "github.com/fsnotify/fsnotify" + "github.com/google/go-github/github" "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/oauth2" ) var ( - log *logrus.Logger + log *logrus.Entry defaultLogLevel = logrus.InfoLevel rootCmdFile string rootCmdCfg *viper.Viper + + since time.Time // The earliest GitHub issue updates we want to retrieve + ghIDFieldID string // The customfield ID of the GitHub ID field in JIRA + ghNumFieldID string // The customfield ID of the GitHub Number field in JIRA + ghLabelsFieldID string // The customfield ID of the GitHub Labels field in JIRA + isLastUpdateFieldID string // The customfield ID of the Last Issue-Sync Update field in JIRA + + project jira.Project ) +const dateFormat = "2006-01-02T15:04:05-0700" + func Execute() { if err := RootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) + log.Fatal(err) + } +} + +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 := 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.Debugln("Successfully connected to GitHub.") + return client, nil +} + +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 := 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) } + project = *proj + + log.Debug("Successfully connected to JIRA.") + return client, nil } 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", - PersistentPreRun: func(cmd *cobra.Command, args []string) { + PreRun: func(cmd *cobra.Command, args []string) { rootCmdCfg.BindPFlags(cmd.Flags()) - initRootLogger() + log = newLogger("issue-sync", rootCmdCfg.GetString("log-level")) }, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("Called root job; not written yet") + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateConfig(); err != nil { + return err + } + + ghClient, err := GetGitHubClient(rootCmdCfg.GetString("github-token")) + if err != nil { + return err + } + jiraClient, err := GetJIRAClient( + rootCmdCfg.GetString("jira-user"), + rootCmdCfg.GetString("jira-pass"), + rootCmdCfg.GetString("jira-uri"), + ) + if err != nil { + return err + } + + if err := getFieldIDs(*jiraClient); err != nil { + return err + } + + if err := compareIssues(*ghClient, *jiraClient); err != nil { + return err + } + + return setLastUpdateTime() }, } +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 == "" { + return errors.New("Jira password required") + } + + 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 +} + +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"` +} + +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 = client.Do(req, fields) + 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 "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 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 +} + +func compareIssues(ghClient github.Client, jiraClient jira.Client) error { + log.Debug("Collecting issues") + ctx := context.Background() + + repo := strings.Split(rootCmdCfg.GetString("repo-name"), "/") + + ghIssues, _, err := ghClient.Issues.ListByRepo(ctx, repo[0], repo[1], &github.IssueListByRepoOptions{ + Since: since, + ListOptions: github.ListOptions{ + PerPage: 100, + }, + }) + if err != nil { + return err + } + 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)", + rootCmdCfg.GetString("jira-project"), ghIDFieldID, strings.Join(ids, ",")) + + jiraIssues, _, err := jiraClient.Issue.Search(jql, nil) + if err != nil { + return err + } + + log.Debug("Collected all JIRA issues") + + 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 { + found = true + if err := updateIssue(*ghIssue, jIssue); err != nil { + log.Errorf("Error updating issue %s. Error: %v", jIssue.Key, err) + } + break + } + } + if !found { + if err := createIssue(*ghIssue, jiraClient); err != nil { + log.Errorf("Error creating issue for #%d. Error: %v", *ghIssue.Number, err) + } + } + } + + return nil +} + +func updateIssue(ghIssue github.Issue, jIssue jira.Issue) error { + log.Debugf("Updating JIRA issue %s with GitHub issue %d", jIssue.ID, *ghIssue.ID) + return nil +} + +func createIssue(issue github.Issue, client jira.Client) error { + log.Debugf("Creating JIRA issue based on GitHub issue #%d", *issue.Number) + return nil +} + +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"` + JiraPass string `json:"jira-pass" mapstructure:"jira-pass"` + 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"` +} + +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.New() + 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("github-token", "", "Set the API Token used to access the GitHub repo") -} - -func initRootLogger() { - log = logrus.New() + 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") + RootCmd.PersistentFlags().StringP("repo-name", "r", "", "Set the repository path (should be form owner/repo)") + 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") - logLevel := parseLogLevel(rootCmdCfg.GetString("log-level")) - log.Level = logLevel - log.WithField("log-level", logLevel).Debug("root log level set") } func parseLogLevel(level string) logrus.Level { diff --git a/cmd/version.go b/cmd/version.go index 3ae898a..411edca 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -13,17 +13,19 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" ) -var Version = "0.1.0" +var Version = "undefined" // versionCmd represents the version command var versionCmd = &cobra.Command{ Use: "version", Short: "Returns the current version of issue-sync", Run: func(cmd *cobra.Command, args []string) { - log.Println("issue-sync version ", Version) + fmt.Println("issue-sync version ", Version) }, }