diff --git a/internal/cli/app.go b/internal/cli/app.go index e4bf7e8..54297eb 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker/client" "github.com/pkg/errors" "github.com/strongdm/comply/internal/config" + "github.com/strongdm/comply/internal/jira" "github.com/strongdm/comply/internal/plugin/github" "github.com/urfave/cli" ) @@ -55,6 +56,7 @@ func newApp() *cli.App { // Plugins github.Register() + jira.Register() return app } diff --git a/internal/cli/procedure.go b/internal/cli/procedure.go index 89ee608..4e7acd4 100644 --- a/internal/cli/procedure.go +++ b/internal/cli/procedure.go @@ -3,6 +3,7 @@ package cli import ( "fmt" + "github.com/strongdm/comply/internal/config" "github.com/strongdm/comply/internal/model" "github.com/urfave/cli" ) @@ -28,14 +29,22 @@ func procedureAction(c *cli.Context) error { procedureID := c.Args().First() + ts, err := config.Config().TicketSystem() + if err != nil { + return cli.NewExitError("error in ticket system configuration", 1) + } + + tp := model.GetPlugin(model.TicketSystem(ts)) + for _, procedure := range procedures { if procedure.ID == procedureID { - // TODO: don't hardcode GH - tp := model.GetPlugin(model.GitHub) - tp.Create(&model.Ticket{ + err = tp.Create(&model.Ticket{ Name: procedure.Name, Body: fmt.Sprintf("%s\n\n\n---\nProcedure-ID: %s", procedure.Body, procedure.ID), }, []string{"comply", "comply-procedure"}) + if err != nil { + return err + } return nil } } diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 76ba710..569d3e6 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -1,6 +1,7 @@ package cli import ( + "github.com/strongdm/comply/internal/config" "github.com/strongdm/comply/internal/model" "github.com/urfave/cli" ) @@ -13,8 +14,12 @@ var syncCommand = cli.Command{ } func syncAction(c *cli.Context) error { - // TODO: unhardcode plugin - tp := model.GetPlugin(model.GitHub) + ts, err := config.Config().TicketSystem() + if err != nil { + return cli.NewExitError("error in ticket system configuration", 1) + } + + tp := model.GetPlugin(model.TicketSystem(ts)) tickets, err := tp.FindByTagName("comply") if err != nil { return err diff --git a/internal/config/config.go b/internal/config/config.go index cdac061..e10000e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "io/ioutil" "os" "path/filepath" @@ -12,6 +13,12 @@ var projectRoot string var dockerAvailable, pandocAvailable bool +const ( + Jira = "jira" + GitHub = "github" + NoTickets = "none" +) + const ( // UseDocker invokes pandoc within Docker UseDocker = "docker" @@ -73,14 +80,14 @@ func Exists() bool { } // Config is the parsed contents of ProjectRoot()/config.yml. -func Config() Project { +func Config() *Project { p := Project{} cfgBytes, err := ioutil.ReadFile(filepath.Join(ProjectRoot(), "comply.yml")) if err != nil { panic("unable to load config.yml: " + err.Error()) } yaml.Unmarshal(cfgBytes, &p) - return p + return &p } // ProjectRoot is the fully-qualified path to the root directory. @@ -95,3 +102,27 @@ func ProjectRoot() string { return projectRoot } + +// TicketSystem indicates the type of the configured ticket system +func (p *Project) TicketSystem() (string, error) { + if len(p.Tickets) > 1 { + return NoTickets, errors.New("multiple ticket systems configured") + } + + for k := range p.Tickets { + switch k { + case GitHub: + return GitHub, nil + case Jira: + return Jira, nil + case NoTickets: + return NoTickets, nil + default: + // explicit error for this case + return "", errors.New("unrecognized ticket system configured") + } + } + + // no ticket block configured + return NoTickets, nil +} diff --git a/internal/jira/jira.go b/internal/jira/jira.go new file mode 100644 index 0000000..a7d7219 --- /dev/null +++ b/internal/jira/jira.go @@ -0,0 +1,200 @@ +package jira + +import ( + "fmt" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/strongdm/comply/internal/model" + + jira "github.com/andygrunwald/go-jira" +) + +const ( + cfgUsername = "username" + cfgPassword = "password" + cfgURL = "url" + cfgProject = "project" +) + +var prompts = map[string]string{ + cfgUsername: "Jira Username", + cfgPassword: "Jira Password", + cfgURL: "Jira URL", + cfgProject: "Jira Project Code", +} + +// Prompts are human-readable configuration element names +func (j *jiraPlugin) Prompts() map[string]string { + return prompts +} + +// Register causes the Github plugin to register itself +func Register() { + model.Register(model.Jira, &jiraPlugin{}) +} + +type jiraPlugin struct { + username string + password string + url string + project string + + clientMu sync.Mutex + client *jira.Client +} + +func (j *jiraPlugin) api() *jira.Client { + j.clientMu.Lock() + defer j.clientMu.Unlock() + + if j.client == nil { + tp := jira.BasicAuthTransport{ + Username: j.username, + Password: j.password, + } + + client, _ := jira.NewClient(tp.Client(), j.url) + j.client = client + } + return j.client +} + +func (j *jiraPlugin) Get(ID string) (*model.Ticket, error) { + return nil, nil +} + +func (j *jiraPlugin) Configured() bool { + return j.username != "" && j.password != "" && j.url != "" && j.project != "" +} + +func (j *jiraPlugin) Links() model.TicketLinks { + links := model.TicketLinks{} + links.AuditAll = fmt.Sprintf("%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aaudit", j.url) + links.AuditOpen = fmt.Sprintf("%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aaudit", j.url) + links.ProcedureAll = fmt.Sprintf("%s/issues?q=is%3Aissue+label%3Acomply+label%3Aprocedure", j.url) + links.ProcedureOpen = fmt.Sprintf("%s/issues?q=is%3Aissue+is%3Aopen+label%3Acomply+label%3Aprocedure", j.url) + return links +} + +func (j *jiraPlugin) Configure(cfg map[string]interface{}) error { + var err error + + if j.username, err = getCfg(cfg, cfgUsername); err != nil { + return err + } + if j.password, err = getCfg(cfg, cfgPassword); err != nil { + return err + } + if j.url, err = getCfg(cfg, cfgURL); err != nil { + return err + } + if j.project, err = getCfg(cfg, cfgProject); err != nil { + return err + } + + return nil +} + +func getCfg(cfg map[string]interface{}, k string) (string, error) { + v, ok := cfg[k] + if !ok { + return "", errors.New("Missing key: " + k) + } + + vS, ok := v.(string) + if !ok { + return "", errors.New("Malformatted key: " + k) + } + return vS, nil +} + +func (j *jiraPlugin) FindOpen() ([]*model.Ticket, error) { + return []*model.Ticket{}, nil + // issues, _, err := j.api().Issues.ListByRepo(context.Background(), j.username, j.reponame, &github.IssueListByRepoOptions{ + // State: "open", + // }) + + // if err != nil { + // return nil, errors.Wrap(err, "error during FindOpen") + // } + + // return toTickets(issues), nil +} + +func (j *jiraPlugin) FindByTag(name, value string) ([]*model.Ticket, error) { + panic("not implemented") +} + +func (j *jiraPlugin) FindByTagName(name string) ([]*model.Ticket, error) { + return []*model.Ticket{}, nil + // issues, _, err := j.api().Issues.ListByRepo(context.Background(), j.username, j.reponame, &github.IssueListByRepoOptions{ + // State: "all", + // Labels: []string{name}, + // }) + + // if err != nil { + // return nil, errors.Wrap(err, "error during FindOpen") + // } + + // return toTickets(issues), nil +} + +func (j *jiraPlugin) LinkFor(t *model.Ticket) string { + // return fmt.Sprintf("https://github.com/strongdm/comply/issues/%s", t.ID) + panic("not implemented") +} + +func (j *jiraPlugin) Create(ticket *model.Ticket, labels []string) error { + i := jira.Issue{ + Fields: &jira.IssueFields{ + Type: jira.IssueType{ + Name: "Task", + }, + Project: jira.Project{ + Key: j.project, + }, + Summary: ticket.Name, + Description: ticket.Body, + Labels: labels, + }, + } + + _, _, err := j.api().Issue.Create(&i) + if err != nil { + return errors.Wrap(err, "unable to create ticket") + } + return nil +} + +func toTickets(issues []*jira.Issue) []*model.Ticket { + var tickets []*model.Ticket + for _, i := range issues { + tickets = append(tickets, toTicket(i)) + } + return tickets +} + +func toTicket(i *jira.Issue) *model.Ticket { + t := &model.Ticket{Attributes: make(map[string]interface{})} + t.ID = i.ID + t.Name = i.Fields.Description + t.Body = i.Fields.Summary + createdAt := time.Time(i.Fields.Created) + t.CreatedAt = &createdAt + t.State = toState(i.Fields.Status) + + for _, l := range i.Fields.Labels { + t.SetBool(l) + } + return t +} + +func toState(status *jira.Status) model.TicketState { + switch status.Name { + case "Closed": + return model.Closed + } + return model.Open +} diff --git a/internal/jira/jira_test.go b/internal/jira/jira_test.go new file mode 100644 index 0000000..1e662ef --- /dev/null +++ b/internal/jira/jira_test.go @@ -0,0 +1,9 @@ +package jira + +import ( + "testing" +) + +func TestJira(t *testing.T) { + createOne() +} diff --git a/internal/model/plugin.go b/internal/model/plugin.go index e3ce58d..5d2fd53 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -17,11 +17,11 @@ type TicketSystem string const ( // Jira from Atlassian. - Jira = TicketSystem("jira") + Jira = TicketSystem(config.Jira) // GitHub from GitHub. - GitHub = TicketSystem("github") + GitHub = TicketSystem(config.GitHub) // NoTickets indicates no ticketing system integration. - NoTickets = TicketSystem("none") + NoTickets = TicketSystem(config.NoTickets) ) type TicketLinks struct { diff --git a/internal/plugin/github/github.go b/internal/plugin/github/github.go index 21591e5..9510e44 100644 --- a/internal/plugin/github/github.go +++ b/internal/plugin/github/github.go @@ -135,7 +135,8 @@ func (g *githubPlugin) FindByTagName(name string) ([]*model.Ticket, error) { } func (g *githubPlugin) LinkFor(t *model.Ticket) string { - return fmt.Sprintf("https://github.com/strongdm/comply/issues/%s", t.ID) + // return fmt.Sprintf("https://github.com/strongdm/comply/issues/%s", t.ID) + panic("not implemented") } func (g *githubPlugin) Create(ticket *model.Ticket, labels []string) error { diff --git a/internal/render/controller.go b/internal/render/controller.go index 2778e54..3ac0398 100644 --- a/internal/render/controller.go +++ b/internal/render/controller.go @@ -5,6 +5,7 @@ import ( "sort" "time" + "github.com/pkg/errors" "github.com/strongdm/comply/internal/config" "github.com/strongdm/comply/internal/model" ) @@ -93,8 +94,12 @@ func load() (*model.Data, *renderData, error) { rd.Name = project.OrganizationName rd.Controls = controls - // TODO: unhardcode plugin - tp := model.GetPlugin(model.GitHub) + ts, err := config.Config().TicketSystem() + if err != nil { + return nil, nil, errors.Wrap(err, "error in ticket system configuration") + } + + tp := model.GetPlugin(model.TicketSystem(ts)) if tp.Configured() { links := tp.Links() rd.Links = &links diff --git a/internal/ticket/scheduler.go b/internal/ticket/scheduler.go index 6103024..5ff2fda 100644 --- a/internal/ticket/scheduler.go +++ b/internal/ticket/scheduler.go @@ -5,7 +5,9 @@ import ( "sort" "time" + "github.com/pkg/errors" "github.com/robfig/cron" + "github.com/strongdm/comply/internal/config" "github.com/strongdm/comply/internal/model" ) @@ -68,7 +70,10 @@ func TriggerScheduled() error { // in the future, nothing to do continue } - trigger(procedure) + err = trigger(procedure) + if err != nil { + return err + } } else { // don't go back further than 13 months tooOld := time.Now().Add(-1 * time.Hour * 24 * (365 + 30)) @@ -88,7 +93,10 @@ func TriggerScheduled() error { } // is in the past? then trigger. - trigger(procedure) + err = trigger(procedure) + if err != nil { + return err + } break SEARCH } } @@ -97,13 +105,18 @@ func TriggerScheduled() error { return nil } -func trigger(procedure *model.Procedure) { +func trigger(procedure *model.Procedure) error { fmt.Printf("triggering procedure %s (cron expression: %s)\n", procedure.Name, procedure.Cron) - // TODO: don't hardcode GH - tp := model.GetPlugin(model.GitHub) - tp.Create(&model.Ticket{ + ts, err := config.Config().TicketSystem() + if err != nil { + return errors.Wrap(err, "error in ticket system configuration") + } + + tp := model.GetPlugin(model.TicketSystem(ts)) + err = tp.Create(&model.Ticket{ Name: procedure.Name, Body: fmt.Sprintf("%s\n\n\n---\nProcedure-ID: %s", procedure.Body, procedure.ID), }, []string{"comply", "comply-procedure"}) + return err }