diff --git a/cfg/config.go b/cfg/config.go index 5c9118f..2635d82 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -48,6 +48,12 @@ type fields struct { lastUpdate string } +// Project represents the project configuration as it exists in the configuration file. +type Project struct { + Repo string `json:"repo" mapstructure:"repo"` + Key string `json:"key" mapstructure:"key"` +} + // 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). @@ -64,8 +70,8 @@ type Config struct { // 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 + // projects represents the mapping from the GitHub repos to JIRA projects the user configured. + projects map[string]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. @@ -101,21 +107,27 @@ func NewConfig(cmd *cobra.Command) (Config, error) { // 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 := 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) - defer res.Body.Close() - body, err := ioutil.ReadAll(res.Body) + var projects []Project + + c.cmdConfig.UnmarshalKey("projects", &projects) + + for _, project := range projects { + proj, res, err := client.Project.Get(project.Key) if err != nil { - c.log.Errorf("Error occured trying to read error body: %v", err) - return err + c.log.Errorf("Error retrieving JIRA project; check key and credentials. Error: %v", err) + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + if err != nil { + c.log.Errorf("Error occured trying to read error body: %v", err) + return err + } + c.log.Debugf("Error body: %s", body) + return errors.New(string(body)) } - - c.log.Debugf("Error body: %s", body) - return errors.New(string(body)) + c.projects[project.Repo] = *proj } - c.project = *proj + var err error c.fieldIDs, err = c.getFieldIDs(client) if err != nil { return err @@ -185,20 +197,38 @@ 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 +// GetProjects returns the map of GitHub repos and JIRA projects, which is useful +// for iterating with. +func (c Config) GetProjects() map[string]jira.Project { + return c.projects } -// GetProjectKey returns the JIRA key of the configured project. -func (c Config) GetProjectKey() string { - return c.project.Key +// GetProject returns the JIRA project for a GitHub repo. +func (c Config) GetProject(repo string) jira.Project { + return c.projects[repo] } -// 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, "/") +// GetProjectKey returns the JIRA key of the project for a GitHub repo. +func (c Config) GetProjectKey(repo string) string { + return c.projects[repo].Key +} + +// GetRepoList returns the list of GitHub repo names provided. +func (c Config) GetRepoList() []string { + keys := make([]string, len(c.projects)) + + i := 0 + for k := range c.projects { + keys[i] = k + i++ + } + + return keys +} + +// GetRepo returns the user/org name and the repo name of the given GitHub repository. +func (c Config) GetRepo(repo string) (string, string) { + parts := strings.Split(repo, "/") // We check that repo-name is two parts separated by a slash in NewConfig, so this is safe return parts[0], parts[1] } @@ -222,6 +252,7 @@ type configFile struct { RepoName string `json:"repo-name" mapstructure:"repo-name"` JIRAURI string `json:"jira-uri" mapstructure:"jira-uri"` JIRAProject string `json:"jira-project" mapstructure:"jira-project"` + Projects []jira.Project `json:"projects" mapstructure:"projects"` Since string `json:"since" mapstructure:"since"` Timeout time.Duration `json:"timeout" mapstructure:"timeout"` } @@ -380,14 +411,6 @@ func (c *Config) validateConfig() error { } } - 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") @@ -396,9 +419,49 @@ func (c *Config) validateConfig() error { return errors.New("JIRA URI must be valid URI") } - project := c.cmdConfig.GetString("jira-project") - if project == "" { - return errors.New("JIRA project required") + if c.cmdConfig.GetString("jira-project") != "" || c.cmdConfig.GetString("repo-name") != "" { + c.log.Debug("Using provided project and repo") + + 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") + } + + project := c.cmdConfig.GetString("jira-project") + if project == "" { + return errors.New("JIRA project required") + } + + projects := make([]Project, 1) + projects[0] = Project{ + Repo: repo, + Key: project, + } + + c.cmdConfig.Set("projects", projects) + c.cmdConfig.Set("repo-name", "") + c.cmdConfig.Set("jira-project", "") + } else { + c.log.Debug("Using project list from configuration file") + + var projects []Project + + c.cmdConfig.UnmarshalKey("projects", &projects) + + for i, project := range projects { + if project.Repo == "" { + return fmt.Errorf("project number %d is missing a repo", i) + } + if !strings.Contains(project.Repo, "/") || len(strings.Split(project.Repo, "/")) != 2 { + return fmt.Errorf("project number %d has bad repo; must be user/repo or org/repo", i) + } + if project.Key == "" { + return fmt.Errorf("project number %d is missing JIRA project key", i) + } + } } sinceStr := c.cmdConfig.GetString("since") diff --git a/cmd/root.go b/cmd/root.go index 6625211..7ad1521 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "time" "github.com/Sirupsen/logrus" + "github.com/andygrunwald/go-jira" "github.com/coreos/issue-sync/cfg" "github.com/coreos/issue-sync/lib" "github.com/coreos/issue-sync/lib/clients" @@ -30,17 +31,28 @@ var RootCmd = &cobra.Command{ return err } - ghClient, err := clients.NewGitHubClient(config) - if err != nil { - return err - } - jiraClient, err := clients.NewJIRAClient(&config) + // Create a temporary JIRA client which we can use to populate the + // configuration object with all of the JIRA settings (projects, + // field IDs, etc.) + rootJCli, err := clients.NewJIRAClient(config, jira.Project{}) if err != nil { return err } + config.LoadJIRAConfig(rootJCli.GetClient()) - if err := lib.CompareIssues(config, ghClient, jiraClient); err != nil { - return err + for _, repo := range config.GetRepoList() { + ghClient, err := clients.NewGitHubClient(config, repo) + if err != nil { + return err + } + jiraClient, err := clients.NewJIRAClient(config, config.GetProject(repo)) + if err != nil { + return err + } + + if err := lib.CompareIssues(config, ghClient, jiraClient); err != nil { + return err + } } if !config.IsDryRun() { diff --git a/lib/clients/github.go b/lib/clients/github.go index 0509231..4aa1404 100644 --- a/lib/clients/github.go +++ b/lib/clients/github.go @@ -18,6 +18,8 @@ type GitHubClient interface { ListComments(issue github.Issue) ([]*github.IssueComment, error) GetUser(login string) (github.User, error) GetRateLimits() (github.RateLimits, error) + GetRepo() string + GetRepoSplit() (string, string) } // realGHClient is a standard GitHub clients, that actually makes all of the @@ -26,6 +28,7 @@ type GitHubClient interface { type realGHClient struct { config cfg.Config client github.Client + repo string } // ListIssues returns the list of GitHub issues since the last run of the tool. @@ -34,7 +37,7 @@ func (g realGHClient) ListIssues() ([]*github.Issue, error) { ctx := context.Background() - user, repo := g.config.GetRepo() + user, repo := g.GetRepoSplit() i, _, err := g.request(func() (interface{}, *github.Response, error) { return g.client.Issues.ListByRepo(ctx, user, repo, &github.IssueListByRepoOptions{ @@ -65,7 +68,7 @@ func (g realGHClient) ListComments(issue github.Issue) ([]*github.IssueComment, log := g.config.GetLogger() ctx := context.Background() - user, repo := g.config.GetRepo() + user, repo := g.GetRepoSplit() c, _, err := g.request(func() (interface{}, *github.Response, error) { return g.client.Issues.ListComments(ctx, user, repo, issue.GetNumber(), &github.IssueListCommentsOptions{ Sort: "created", @@ -129,6 +132,18 @@ func (g realGHClient) GetRateLimits() (github.RateLimits, error) { return *rate, nil } +// GetRepo returns the user/repo form name of the GitHub repository the client +// has been configured with. +func (g realGHClient) GetRepo() string { + return g.repo +} + +// GetRepoSplit returns the username and repo name of the GitHub repository +// the client has been configured with. +func (g realGHClient) GetRepoSplit() (string, string) { + return g.config.GetRepo(g.repo) +} + // 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 @@ -160,7 +175,7 @@ func (g realGHClient) request(f func() (interface{}, *github.Response, error)) ( // 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) { +func NewGitHubClient(config cfg.Config, repo string) (GitHubClient, error) { var ret GitHubClient log := config.GetLogger() @@ -176,6 +191,7 @@ func NewGitHubClient(config cfg.Config) (GitHubClient, error) { ret = realGHClient{ config: config, client: *client, + repo: repo, } // Make a request so we can check that we can connect fine. diff --git a/lib/clients/jira.go b/lib/clients/jira.go index c7bc222..eb4e8e4 100644 --- a/lib/clients/jira.go +++ b/lib/clients/jira.go @@ -44,19 +44,20 @@ type JIRAClient interface { 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) + GetClient() jira.Client } // 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) { +func NewJIRAClient(config cfg.Config, project jira.Project) (JIRAClient, error) { log := config.GetLogger() var oauth *http.Client var err error if !config.IsBasicAuth() { - oauth, err = newJIRAHTTPClient(*config) + oauth, err = newJIRAHTTPClient(config) if err != nil { log.Errorf("Error getting OAuth config: %v", err) return dryrunJIRAClient{}, err @@ -77,29 +78,38 @@ func NewJIRAClient(config *cfg.Config) (JIRAClient, error) { log.Debug("JIRA clients initialized") - config.LoadJIRAConfig(*client) - if config.IsDryRun() { j = dryrunJIRAClient{ - config: *config, - client: *client, + config: config, + client: *client, + project: project, } } else { j = realJIRAClient{ - config: *config, - client: *client, + config: config, + client: *client, + project: project, } } return j, nil } +// GetClient returns the underlying JIRA API client used by our client. +// It's made available for the configuration object to use, since it +// can't import this class due to circular dependencies. If you think +// you want to call this function, consider extending the class first. +func (j realJIRAClient) GetClient() jira.Client { + return j.client +} + // 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 + config cfg.Config + client jira.Client + project jira.Project } // ListIssues returns a list of JIRA issues on the configured project which @@ -109,7 +119,7 @@ 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) + j.project.Key, j.config.GetFieldID(cfg.GitHubID), ids) ji, res, err := j.request(func() (interface{}, *jira.Response, error) { return j.client.Issue.Search(jql, nil) @@ -328,8 +338,9 @@ func (j realJIRAClient) request(f func() (interface{}, *jira.Response, error)) ( // 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 + config cfg.Config + client jira.Client + project jira.Project } // newlineReplaceRegex is a regex to match both "\r\n" and just "\n" newline styles, @@ -351,6 +362,14 @@ func truncate(s string, length int) string { return fmt.Sprintf("%s...", s[0:length]) } +// GetClient returns the underlying JIRA API client used by our client. +// It's made available for the configuration object to use, since it +// can't import this class due to circular dependencies. If you think +// you want to call this function, consider extending the class first. +func (j dryrunJIRAClient) GetClient() jira.Client { + return j.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. @@ -360,7 +379,7 @@ 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) + j.project.Key, j.config.GetFieldID(cfg.GitHubID), ids) ji, res, err := j.request(func() (interface{}, *jira.Response, error) { return j.client.Issue.Search(jql, nil) diff --git a/lib/issues.go b/lib/issues.go index 87bb21d..a6c90c9 100644 --- a/lib/issues.go +++ b/lib/issues.go @@ -154,8 +154,6 @@ func UpdateIssue(config cfg.Config, ghIssue github.Issue, jIssue jira.Issue, ghC log.Debugf("JIRA issue %s is already up to date!", jIssue.Key) } - - if err := CompareComments(config, ghIssue, issue, ghClient, jClient); err != nil { return err } @@ -174,7 +172,7 @@ func CreateIssue(config cfg.Config, issue github.Issue, ghClient clients.GitHubC Type: jira.IssueType{ Name: "Task", // TODO: Determine issue type }, - Project: config.GetProject(), + Project: config.GetProject(ghClient.GetRepo()), Summary: issue.GetTitle(), Description: issue.GetBody(), Unknowns: map[string]interface{}{},