diff --git a/src/github.com/matrix-org/go-neb/services/jira/jira.go b/src/github.com/matrix-org/go-neb/services/jira/jira.go index 21973b2..22cd933 100644 --- a/src/github.com/matrix-org/go-neb/services/jira/jira.go +++ b/src/github.com/matrix-org/go-neb/services/jira/jira.go @@ -10,6 +10,7 @@ import ( "github.com/matrix-org/go-neb/matrix" "github.com/matrix-org/go-neb/plugin" "github.com/matrix-org/go-neb/realms/jira" + "github.com/matrix-org/go-neb/services/jira/webhook" "github.com/matrix-org/go-neb/types" "html" "net/http" @@ -22,14 +23,16 @@ var issueKeyRegex = regexp.MustCompile("([A-z]+)-([0-9]+)") var projectKeyRegex = regexp.MustCompile("^[A-z]+$") type jiraService struct { - id string - BotUserID string - ClientUserID string - Rooms map[string]struct { // room_id => {} - RealmID string // Determines the JIRA endpoint - Projects map[string]struct { // SYN => {} - Expand bool - Track bool + id string + webhookEndpointURL string + BotUserID string + ClientUserID string + Rooms map[string]struct { // room_id => {} + Realms map[string]struct { // realm_id => {} Determines the JIRA endpoint + Projects map[string]struct { // SYN => {} + Expand bool + Track bool + } } } } @@ -44,8 +47,29 @@ func (s *jiraService) RoomIDs() []string { } return keys } -func (s *jiraService) Register() error { return nil } -func (s *jiraService) PostRegister(old types.Service) {} +func (s *jiraService) Register() error { + // We only ever make 1 JIRA webhook which listens for all projects and then filter + // on receive. So we simply need to know if we need to make a webhook or not. We + // need to do this for each unique realm. + for realmID, pkeys := range projectsAndRealmsToTrack(s) { + realm, err := database.GetServiceDB().LoadAuthRealm(realmID) + if err != nil { + return err + } + jrealm, ok := realm.(*realms.JIRARealm) + if !ok { + return errors.New("Realm ID doesn't map to a JIRA realm") + } + + if err = webhook.RegisterHook(jrealm, pkeys, s.ClientUserID, s.webhookEndpointURL); err != nil { + return err + } + } + return nil +} +func (s *jiraService) PostRegister(old types.Service) { + // TODO: We don't remove old JIRA webhooks for now. Let the admin sort it out. +} func (s *jiraService) cmdJiraCreate(roomID, userID string, args []string) (interface{}, error) { // E.g jira create PROJ "Issue title" "Issue desc" @@ -125,21 +149,23 @@ func (s *jiraService) expandIssue(roomID, userID, issueKey string) interface{} { } projectKey := groups[1] - if !s.Rooms[roomID].Projects[projectKey].Expand { + + realmID := s.realmIDForProject(roomID, projectKey) + if realmID == "" { return nil } - r, err := database.GetServiceDB().LoadAuthRealm(s.Rooms[roomID].RealmID) + r, err := database.GetServiceDB().LoadAuthRealm(realmID) if err != nil { logger.WithFields(log.Fields{ - "realm_id": s.Rooms[roomID].RealmID, + "realm_id": realmID, log.ErrorKey: err, }).Print("Failed to load realm") return nil } jrealm, ok := r.(*realms.JIRARealm) if !ok { - logger.WithField("realm_id", s.Rooms[roomID].RealmID).Print( + logger.WithField("realm_id", realmID).Print( "Realm cannot be typecast to JIRARealm", ) } @@ -194,8 +220,21 @@ func (s *jiraService) Plugin(roomID string) plugin.Plugin { }, } } + func (s *jiraService) OnReceiveWebhook(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { - w.WriteHeader(200) // Do nothing + webhook.OnReceiveRequest(w, req, cli) +} + +func (s *jiraService) realmIDForProject(roomID, projectKey string) string { + // TODO: Multiple realms with the same pkey will be randomly chosen. + for r, realmConfig := range s.Rooms[roomID].Realms { + for pkey, projectConfig := range realmConfig.Projects { + if pkey == projectKey && projectConfig.Expand { + return r + } + } + } + return "" } func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, error) { @@ -261,6 +300,23 @@ func (s *jiraService) projectToRealm(userID, pkey string) (*realms.JIRARealm, er return nil, nil } +// Returns realm_id => [PROJ, ECT, KEYS] +func projectsAndRealmsToTrack(s *jiraService) map[string][]string { + ridsToProjects := make(map[string][]string) + for _, roomConfig := range s.Rooms { + for realmID, realmConfig := range roomConfig.Realms { + for projectKey, projectConfig := range realmConfig.Projects { + if projectConfig.Track { + ridsToProjects[realmID] = append( + ridsToProjects[realmID], projectKey, + ) + } + } + } + } + return ridsToProjects +} + func htmlSummaryForIssue(issue *jira.Issue) string { // form a summary of the issue being affected e.g: // "Flibble Wibble [P1, In Progress]" @@ -281,6 +337,6 @@ func htmlSummaryForIssue(issue *jira.Issue) string { func init() { types.RegisterService(func(serviceID, webhookEndpointURL string) types.Service { - return &jiraService{id: serviceID} + return &jiraService{id: serviceID, webhookEndpointURL: webhookEndpointURL} }) } diff --git a/src/github.com/matrix-org/go-neb/services/jira/webhook/webhook.go b/src/github.com/matrix-org/go-neb/services/jira/webhook/webhook.go new file mode 100644 index 0000000..2851275 --- /dev/null +++ b/src/github.com/matrix-org/go-neb/services/jira/webhook/webhook.go @@ -0,0 +1,169 @@ +package webhook + +import ( + "fmt" + log "github.com/Sirupsen/logrus" + "github.com/andygrunwald/go-jira" + "github.com/matrix-org/go-neb/errors" + "github.com/matrix-org/go-neb/matrix" + "github.com/matrix-org/go-neb/realms/jira" + "net/http" +) + +type jiraWebhook struct { + Name string `json:"name"` + URL string `json:"url"` + Events []string `json:"events"` + Filter string `json:"jqlFilter"` + Exclude bool `json:"excludeIssueDetails"` + // These fields are populated on GET + Enabled bool `json:"enabled"` +} + +// RegisterHook checks to see if this user is allowed to track the given projects and then tracks them. +func RegisterHook(jrealm *realms.JIRARealm, projects []string, userID, webhookEndpointURL string) error { + // Tracking means that a webhook may need to be created on the remote JIRA installation. + // We need to make sure that the user has permission to do this. If they don't, it may still be okay if + // there is an existing webhook set up for this installation by someone else, *PROVIDED* that the projects + // they wish to monitor are "public" (accessible by not logged in users). + // + // The methodology for this is as follows: + // - If they don't have a JIRA token for the remote install, fail. + // - Try to GET /webhooks. If this succeeds: + // * The user is an admin (only admins can GET webhooks) + // * If there is a NEB webhook already then return success. + // * Else create the webhook and then return success (if creation fails then fail). + // - Else: + // * The user is NOT an admin. + // * Are ALL the projects in the config public? If yes: + // - Is there an existing config for this remote JIRA installation? If yes: + // * Another user has setup a webhook. We can't check if the webhook is still alive though, + // return success. + // - Else: + // * There is no existing NEB webhook for this JIRA installation. The user cannot create a + // webhook to the JIRA installation, so fail. + // * Else: + // - There are private projects in the config and the user isn't an admin, so fail. + logger := log.WithFields(log.Fields{ + "realm_id": jrealm.ID(), + "jira_url": jrealm.JIRAEndpoint, + "user_id": userID, + }) + cli, err := jrealm.JIRAClient(userID, false) + if err != nil { + logger.WithError(err).Print("No JIRA client exists") + return err // no OAuth token on this JIRA endpoint + } + wh, httpErr := getWebhook(cli, webhookEndpointURL) + if httpErr != nil { + if httpErr.Code != 403 { + logger.WithError(httpErr).Print("Failed to GET webhook") + return httpErr + } + // User is not a JIRA admin (cannot GET webhooks) + // The only way this is going to end well for this request is if all the projects + // are PUBLIC. That is, they can be accessed directly without an access token. + httpErr = checkProjectsArePublic(jrealm, projects, userID) + if httpErr != nil { + logger.WithError(httpErr).Print("Failed to assert that all projects are public") + return httpErr + } + + // All projects that wish to be tracked are public, but the user cannot create + // webhooks. The only way this will work is if we already have a webhook for this + // JIRA endpoint. + // TODO: Check for an existing webhook for this realm (flag on realm?) + return fmt.Errorf("Not supported yet") + } + + // The user is probably an admin (can query webhooks endpoint) + + if wh != nil { + logger.Print("Webhook already exists") + return nil // we already have a NEB webhook :D + } + return createWebhook(jrealm, webhookEndpointURL, userID) +} + +// OnReceiveRequest is called when JIRA hits NEB with an update +func OnReceiveRequest(w http.ResponseWriter, req *http.Request, cli *matrix.Client) { + w.WriteHeader(200) // Do nothing +} + +func createWebhook(jrealm *realms.JIRARealm, webhookEndpointURL, userID string) error { + cli, err := jrealm.JIRAClient(userID, false) + + req, err := cli.NewRequest("POST", "rest/webhooks/1.0/webhook", jiraWebhook{ + Name: "Go-NEB", + URL: webhookEndpointURL, + Events: []string{"jira:issue_created", "jira:issue_deleted", "jira:issue_updated"}, + Filter: "", + Exclude: false, + }) + if err != nil { + return err + } + res, err := cli.Do(req, nil) + if err != nil { + return err + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("Creating webhook returned HTTP %d", res.StatusCode) + } + log.WithFields(log.Fields{ + "status_code": res.StatusCode, + "realm_id": jrealm.ID(), + "jira_url": jrealm.JIRAEndpoint, + }).Print("Created webhook") + return nil +} + +func getWebhook(cli *jira.Client, webhookEndpointURL string) (*jiraWebhook, *errors.HTTPError) { + req, err := cli.NewRequest("GET", "rest/webhooks/1.0/webhook", nil) + if err != nil { + return nil, &errors.HTTPError{err, "Failed to prepare webhook request", 500} + } + var webhookList []jiraWebhook + res, err := cli.Do(req, &webhookList) + if err != nil { + return nil, &errors.HTTPError{err, "Failed to query webhooks", 502} + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, &errors.HTTPError{ + err, + fmt.Sprintf("Querying webhook returned HTTP %d", res.StatusCode), + 403, + } + } + log.Print("Retrieved ", len(webhookList), " webhooks") + var nebWH *jiraWebhook + for _, wh := range webhookList { + if wh.URL == webhookEndpointURL { + nebWH = &wh + break + } + } + return nebWH, nil +} + +func checkProjectsArePublic(jrealm *realms.JIRARealm, projects []string, userID string) *errors.HTTPError { + publicCli, err := jrealm.JIRAClient("", true) + if err != nil { + return &errors.HTTPError{err, "Cannot create public JIRA client", 500} + } + for _, projectKey := range projects { + // check you can query this project with a public client + req, err := publicCli.NewRequest("GET", "rest/api/2/project/"+projectKey, nil) + if err != nil { + return &errors.HTTPError{err, "Failed to create project URL", 500} + } + res, err := publicCli.Do(req, nil) + if err != nil { + return &errors.HTTPError{err, fmt.Sprintf("Failed to query project %s", projectKey), 500} + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return &errors.HTTPError{err, fmt.Sprintf("Project %s is not public. (HTTP %d)", projectKey, res.StatusCode), 403} + } + } + return nil +}