diff --git a/.gitignore b/.gitignore index 417ddba0a..cbca8cd7d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,8 @@ dist bin vendor +# Dev +*.code-workspace + # Mac .DS_Store diff --git a/go.mod b/go.mod index f3b773ca2..6830ae5f0 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/dyatlov/go-opengraph v0.0.0-20180429202543-816b6608b3c8 // indirect github.com/fatih/structs v1.1.0 // indirect + github.com/go-ldap/ldap v3.0.3+incompatible // indirect github.com/golang/protobuf v1.3.1 // indirect github.com/google/go-querystring v0.0.0-20190318165438-c8c88dbee036 // indirect github.com/google/uuid v1.1.1 // indirect @@ -16,7 +17,7 @@ require ( github.com/hashicorp/go-hclog v0.0.0-20180910232447-e45cbeb79f04 // indirect github.com/hashicorp/go-plugin v0.0.0-20180814222501-a4620f9913d1 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect - github.com/mattermost/mattermost-server v5.6.5+incompatible + github.com/mattermost/mattermost-server v5.10.1+incompatible github.com/mattermost/viper v0.0.0-20181112161711-f99c30686b86 // indirect github.com/mitchellh/go-testing-interface v1.0.0 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect @@ -43,5 +44,6 @@ require ( google.golang.org/appengine v1.5.0 // indirect google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107 // indirect google.golang.org/grpc v1.20.0 // indirect + gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3 // indirect ) diff --git a/go.sum b/go.sum index 4c5b215a5..3a6c07b3f 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-ldap/ldap v3.0.3+incompatible h1:HTeSZO8hWMS1Rgb2Ziku6b8a7qRIZZMHjsvuZyatzwk= +github.com/go-ldap/ldap v3.0.3+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= @@ -47,6 +49,8 @@ github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDe github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattermost/mattermost-server v5.6.5+incompatible h1:3GOnGVeecQ8o7kAQqIERdwblc7ezJ/pSWFkVWZEprLk= github.com/mattermost/mattermost-server v5.6.5+incompatible/go.mod h1:5L6MjAec+XXQwMIt791Ganu45GKsSiM+I0tLR9wUj8Y= +github.com/mattermost/mattermost-server v5.10.1+incompatible h1:bSwuCGIOuKGVCxAMsVeEnBP8J2qxNL4/ik8ibDq+Q9U= +github.com/mattermost/mattermost-server v5.10.1+incompatible/go.mod h1:5L6MjAec+XXQwMIt791Ganu45GKsSiM+I0tLR9wUj8Y= github.com/mattermost/viper v0.0.0-20181112161711-f99c30686b86 h1:7/e3ksOHdNIdjBwKeM6acMWNkdLjwPM9MllJeLdfheY= github.com/mattermost/viper v0.0.0-20181112161711-f99c30686b86/go.mod h1:KAa5zVT6NsZa4/CLpDkT/A0LbI+lVEWMDNSjSHfgFO8= github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= @@ -135,6 +139,8 @@ google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRn google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0 h1:DlsSIrgEBuZAUFJcta2B5i/lzeHHbnfkNFAfFXLVFYQ= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.0.0-20170531160350-a96e63847dc3 h1:AFxeG48hTWHhDTQDk/m2gorfVHUEa9vo3tp3D7TzwjI= diff --git a/server/atlassian_connect.go b/server/atlassian_connect.go index 255f1a10f..dc9feb11a 100644 --- a/server/atlassian_connect.go +++ b/server/atlassian_connect.go @@ -52,7 +52,7 @@ func httpACInstalled(p *Plugin, w http.ResponseWriter, r *http.Request) (int, er // Only allow this operation once, a JIRA instance must already exist // for asc.BaseURL but its EventType would be empty. - ji, err := p.LoadJIRAInstance(asc.BaseURL) + ji, err := p.instanceStore.LoadJIRAInstance(asc.BaseURL) if err != nil { return http.StatusInternalServerError, errors.WithMessage(err, "failed to load instance "+asc.BaseURL) @@ -72,7 +72,7 @@ func httpACInstalled(p *Plugin, w http.ResponseWriter, r *http.Request) (int, er // Create a permanent instance record, also store it as current jiraInstance := NewJIRACloudInstance(p, asc.BaseURL, true, string(body), &asc) - err = p.StoreJIRAInstance(jiraInstance) + err = p.instanceStore.StoreJIRAInstance(jiraInstance) if err != nil { return http.StatusInternalServerError, err } diff --git a/server/auth_token.go b/server/auth_token.go index fb1ff9ebe..6151b6596 100644 --- a/server/auth_token.go +++ b/server/auth_token.go @@ -31,7 +31,7 @@ func (p *Plugin) NewEncodedAuthToken(mattermostUserID, secret string) (returnTok returnErr = errors.WithMessage(returnErr, "failed to create auth token") }() - encryptSecret, err := p.EnsureAuthTokenEncryptSecret() + encryptSecret, err := p.secretsStore.EnsureAuthTokenEncryptSecret() if err != nil { return "", err } @@ -65,7 +65,7 @@ func (p *Plugin) ParseAuthToken(encoded string) (mattermostUserID, tokenSecret s t := AuthToken{} err := func() error { - encryptSecret, err := p.EnsureAuthTokenEncryptSecret() + encryptSecret, err := p.secretsStore.EnsureAuthTokenEncryptSecret() if err != nil { return err } diff --git a/server/command.go b/server/command.go index f59fb4140..472594041 100644 --- a/server/command.go +++ b/server/command.go @@ -105,13 +105,13 @@ func executeSettings(p *Plugin, c *plugin.Context, header *model.CommandArgs, ar return help() } - ji, err := p.LoadCurrentJIRAInstance() + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() if err != nil { return responsef("Failed to load current Jira instance: %v. Please contact your system administrator.", err) } mattermostUserId := header.UserId - jiraUser, err := p.LoadJIRAUser(ji, mattermostUserId) + jiraUser, err := p.userStore.LoadJIRAUser(ji, mattermostUserId) if err != nil { return responsef("Your username is not connected to Jira. Please type `jira connect`. %v", err) } @@ -136,7 +136,7 @@ func executeList(p *Plugin, c *plugin.Context, header *model.CommandArgs, args . return help() } - known, err := p.LoadKnownJIRAInstances() + known, err := p.instanceStore.LoadKnownJIRAInstances() if err != nil { return responsef("Failed to load known Jira instances: %v", err) } @@ -144,7 +144,7 @@ func executeList(p *Plugin, c *plugin.Context, header *model.CommandArgs, args . return responsef("(none installed)\n") } - current, err := p.LoadCurrentJIRAInstance() + current, err := p.currentInstanceStore.LoadCurrentJIRAInstance() if err != nil { return responsef("Failed to load current Jira instance: %v", err) } @@ -156,7 +156,7 @@ func executeList(p *Plugin, c *plugin.Context, header *model.CommandArgs, args . sort.Strings(keys) text := "Known Jira instances (selected instance is **bold**)\n\n| |URL|Type|\n|--|--|--|\n" for i, key := range keys { - ji, err := p.LoadJIRAInstance(key) + ji, err := p.instanceStore.LoadJIRAInstance(key) if err != nil { text += fmt.Sprintf("|%v|%s|error: %v|\n", i+1, key, err) continue @@ -205,7 +205,7 @@ func executeInstallCloud(p *Plugin, c *plugin.Context, header *model.CommandArgs // Create an "uninitialized" instance of Jira Cloud that will // receive the /installed callback - err = p.CreateInactiveCloudInstance(jiraURL) + err = p.instanceStore.CreateInactiveCloudInstance(jiraURL) if err != nil { return responsef(err.Error()) } @@ -264,7 +264,7 @@ func executeInstallServer(p *Plugin, c *plugin.Context, header *model.CommandArg If you see an option to create a Jira issue, you're all set! If not, refer to our [documentation](https://about.mattermost.com/default-jira-plugin) for troubleshooting help. ` ji := NewJIRAServerInstance(p, jiraURL) - err = p.StoreJIRAInstance(ji) + err = p.instanceStore.StoreJIRAInstance(ji) if err != nil { return responsef(err.Error()) } @@ -295,7 +295,7 @@ func executeUninstallCloud(p *Plugin, c *plugin.Context, header *model.CommandAr } jiraURL := args[0] - ji, err := p.LoadCurrentJIRAInstance() + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() if err != nil { return responsef("No current Jira instance to uninstall") } @@ -309,7 +309,7 @@ func executeUninstallCloud(p *Plugin, c *plugin.Context, header *model.CommandAr return responsef("You have entered an incorrect URL. The current Jira instance URL is: `" + jci.GetURL() + "`. Please enter the URL correctly to confirm the uninstall command.") } - err = p.DeleteJiraInstance(jci.GetURL()) + err = p.instanceStore.DeleteJiraInstance(jci.GetURL()) if err != nil { return responsef("Failed to delete Jira instance " + ji.GetURL()) } @@ -341,7 +341,7 @@ func executeUninstallServer(p *Plugin, c *plugin.Context, header *model.CommandA } jiraURL := args[0] - ji, err := p.LoadCurrentJIRAInstance() + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() if err != nil { return responsef("No current Jira instance to uninstall") } @@ -355,7 +355,7 @@ func executeUninstallServer(p *Plugin, c *plugin.Context, header *model.CommandA return responsef("You have entered an incorrect URL. The current Jira instance URL is: `" + jsi.GetURL() + "`. Please enter the URL correctly to confirm the uninstall command.") } - err = p.DeleteJiraInstance(jsi.GetURL()) + err = p.instanceStore.DeleteJiraInstance(jsi.GetURL()) if err != nil { return responsef("Failed to delete Jira instance " + ji.GetURL()) } diff --git a/server/http.go b/server/http.go index e15d3d8bc..8ca1a89a9 100644 --- a/server/http.go +++ b/server/http.go @@ -58,11 +58,11 @@ func handleHTTPRequest(p *Plugin, w http.ResponseWriter, r *http.Request) (int, switch r.URL.Path { // Issue APIs case routeAPICreateIssue: - return withInstance(p, w, r, httpAPICreateIssue) + return withInstance(p.currentInstanceStore, w, r, httpAPICreateIssue) case routeAPIGetCreateIssueMetadata: - return withInstance(p, w, r, httpAPIGetCreateIssueMetadata) + return withInstance(p.currentInstanceStore, w, r, httpAPIGetCreateIssueMetadata) case routeAPIAttachCommentToIssue: - return withInstance(p, w, r, httpAPIAttachCommentToIssue) + return withInstance(p.currentInstanceStore, w, r, httpAPIAttachCommentToIssue) // User APIs case routeAPIUserInfo: @@ -96,9 +96,9 @@ func handleHTTPRequest(p *Plugin, w http.ResponseWriter, r *http.Request) (int, // User connect/disconnect links case routeUserConnect: - return withInstance(p, w, r, httpUserConnect) + return withInstance(p.currentInstanceStore, w, r, httpUserConnect) case routeUserDisconnect: - return withInstance(p, w, r, httpUserDisconnect) + return withInstance(p.currentInstanceStore, w, r, httpUserDisconnect) // Firehose webhook setup for channel subscriptions case routeAPISubscribeWebhook: diff --git a/server/instance.go b/server/instance.go index 346ba4a28..50f68520a 100644 --- a/server/instance.go +++ b/server/instance.go @@ -73,8 +73,8 @@ func (ji *JIRAInstance) Init(p *Plugin) { type withInstanceFunc func(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) -func withInstance(p *Plugin, w http.ResponseWriter, r *http.Request, f withInstanceFunc) (int, error) { - ji, err := p.LoadCurrentJIRAInstance() +func withInstance(store CurrentInstanceStore, w http.ResponseWriter, r *http.Request, f withInstanceFunc) (int, error) { + ji, err := store.LoadCurrentJIRAInstance() if err != nil { return http.StatusInternalServerError, err } diff --git a/server/instance_cloud.go b/server/instance_cloud.go index 5304ad560..75b2c783b 100644 --- a/server/instance_cloud.go +++ b/server/instance_cloud.go @@ -60,7 +60,7 @@ func NewJIRACloudInstance(p *Plugin, key string, installed bool, rawASC string, type withCloudInstanceFunc func(jci *jiraCloudInstance, w http.ResponseWriter, r *http.Request) (int, error) func withCloudInstance(p *Plugin, w http.ResponseWriter, r *http.Request, f withCloudInstanceFunc) (int, error) { - return withInstance(p, w, r, func(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { + return withInstance(p.currentInstanceStore, w, r, func(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { jci, ok := ji.(*jiraCloudInstance) if !ok { return http.StatusBadRequest, errors.New("Must be a JIRA Cloud instance, is " + ji.GetType()) @@ -96,7 +96,7 @@ func (jci jiraCloudInstance) GetUserConnectURL(mattermostUserId string) (string, } secretKey := fmt.Sprintf("%x", sha256.Sum256(secret)) secretValue := "true" - err = jci.Plugin.StoreOneTimeSecret(secretKey, secretValue) + err = jci.Plugin.otsStore.StoreOneTimeSecret(secretKey, secretValue) if err != nil { return "", err } diff --git a/server/instance_server.go b/server/instance_server.go index 834f3ec25..2144ec06b 100644 --- a/server/instance_server.go +++ b/server/instance_server.go @@ -39,7 +39,7 @@ func (jsi jiraServerInstance) GetURL() string { type withServerInstanceFunc func(jsi *jiraServerInstance, w http.ResponseWriter, r *http.Request) (int, error) func withServerInstance(p *Plugin, w http.ResponseWriter, r *http.Request, f withServerInstanceFunc) (int, error) { - return withInstance(p, w, r, func(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { + return withInstance(p.currentInstanceStore, w, r, func(ji Instance, w http.ResponseWriter, r *http.Request) (int, error) { jsi, ok := ji.(*jiraServerInstance) if !ok { return http.StatusBadRequest, errors.New("Must be a Jira Server instance, is " + ji.GetType()) @@ -76,7 +76,7 @@ func (jsi jiraServerInstance) GetUserConnectURL(mattermostUserId string) (return return "", err } - err = jsi.Plugin.StoreOneTimeSecret(token, secret) + err = jsi.Plugin.otsStore.StoreOneTimeSecret(token, secret) if err != nil { return "", err } @@ -136,7 +136,7 @@ func (jsi *jiraServerInstance) GetOAuth1Config() (returnConfig *oauth1.Config, r return oauth1Config, nil } - rsaKey, err := jsi.EnsureRSAKey() + rsaKey, err := jsi.secretsStore.EnsureRSAKey() if err != nil { return nil, err } diff --git a/server/issue.go b/server/issue.go index fd29c4cbb..78bcd2058 100644 --- a/server/issue.go +++ b/server/issue.go @@ -41,7 +41,7 @@ func httpAPICreateIssue(ji Instance, w http.ResponseWriter, r *http.Request) (in return http.StatusUnauthorized, errors.New("not authorized") } - jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mattermostUserId) + jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mattermostUserId) if err != nil { return http.StatusInternalServerError, err } @@ -173,7 +173,7 @@ func httpAPIGetCreateIssueMetadata(ji Instance, w http.ResponseWriter, r *http.R return http.StatusUnauthorized, errors.New("not authorized") } - jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mattermostUserId) + jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mattermostUserId) if err != nil { return http.StatusInternalServerError, err } @@ -235,7 +235,7 @@ func httpAPIAttachCommentToIssue(ji Instance, w http.ResponseWriter, r *http.Req return http.StatusUnauthorized, errors.New("not authorized") } - jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mattermostUserId) + jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mattermostUserId) if err != nil { return http.StatusInternalServerError, err } @@ -339,12 +339,12 @@ func getPermaLink(ji Instance, postId string, post *model.Post) (string, error) } func (p *Plugin) transitionJiraIssue(mmUserId, issueKey, toState string) (string, error) { - ji, err := p.LoadCurrentJIRAInstance() + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() if err != nil { return "", err } - jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mmUserId) + jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mmUserId) if err != nil { return "", err } diff --git a/server/kv.go b/server/kv.go index a5ae11f01..31a2b8237 100644 --- a/server/kv.go +++ b/server/kv.go @@ -9,7 +9,6 @@ import ( "crypto/rsa" "encoding/json" "fmt" - "github.com/mattermost/mattermost-server/model" "github.com/pkg/errors" ) @@ -23,6 +22,54 @@ const ( prefixOneTimeSecret = "ots_" // + unique key that will be deleted after the first verification ) +type Store interface { + CurrentInstanceStore + InstanceStore + UserStore + SecretsStore + OTSStore +} + +type SecretsStore interface { + EnsureAuthTokenEncryptSecret() ([]byte, error) + EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) +} + +type InstanceStore interface { + StoreJIRAInstance(ji Instance) error + CreateInactiveCloudInstance(jiraURL string) error + DeleteJiraInstance(key string) error + LoadJIRAInstance(key string) (Instance, error) + StoreKnownJIRAInstances(known map[string]string) error + LoadKnownJIRAInstances() (map[string]string, error) +} + +type CurrentInstanceStore interface { + StoreCurrentJIRAInstance(ji Instance) error + LoadCurrentJIRAInstance() (Instance, error) +} + +type UserStore interface { + StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JIRAUser) error + LoadJIRAUser(ji Instance, mattermostUserId string) (JIRAUser, error) + LoadMattermostUserId(ji Instance, jiraUserName string) (string, error) + DeleteUserInfo(ji Instance, mattermostUserId string) error +} + +type OTSStore interface { + StoreOneTimeSecret(token, secret string) error + LoadOneTimeSecret(token string) (string, error) + DeleteOneTimeSecret(token string) error +} + +type store struct { + plugin *Plugin +} + +func NewStore(p *Plugin) Store { + return &store{plugin: p} +} + func keyWithInstance(ji Instance, key string) string { if prefixForInstance { h := md5.New() @@ -38,15 +85,15 @@ func hashkey(prefix, key string) string { return fmt.Sprintf("%s%x", prefix, h.Sum(nil)) } -func (p *Plugin) kvGet(key string, v interface{}) (returnErr error) { +func (store store) get(key string, v interface{}) (returnErr error) { defer func() { if returnErr == nil { return } - returnErr = errors.WithMessage(returnErr, "kvGet") + returnErr = errors.WithMessage(returnErr, "failed to get from store") }() - data, appErr := p.API.KVGet(key) + data, appErr := store.plugin.API.KVGet(key) if appErr != nil { return appErr } @@ -63,12 +110,12 @@ func (p *Plugin) kvGet(key string, v interface{}) (returnErr error) { return nil } -func (p *Plugin) kvSet(key string, v interface{}) (returnErr error) { +func (store store) set(key string, v interface{}) (returnErr error) { defer func() { if returnErr == nil { return } - returnErr = errors.WithMessage(returnErr, "kvSet") + returnErr = errors.WithMessage(returnErr, "failed to store") }() data, err := json.Marshal(v) @@ -76,14 +123,14 @@ func (p *Plugin) kvSet(key string, v interface{}) (returnErr error) { return err } - appErr := p.API.KVSet(key, data) + appErr := store.plugin.API.KVSet(key, data) if appErr != nil { return appErr } return nil } -func (p *Plugin) StoreJIRAInstance(ji Instance) (returnErr error) { +func (store store) StoreJIRAInstance(ji Instance) (returnErr error) { defer func() { if returnErr == nil { return @@ -92,36 +139,36 @@ func (p *Plugin) StoreJIRAInstance(ji Instance) (returnErr error) { fmt.Sprintf("failed to store Jira instance:%s", ji.GetURL())) }() - err := p.kvSet(hashkey(prefixJIRAInstance, ji.GetURL()), ji) + err := store.set(hashkey(prefixJIRAInstance, ji.GetURL()), ji) if err != nil { return err } - p.debugf("Stored: JIRA instance: %s", ji.GetURL()) + store.plugin.debugf("Stored: JIRA instance: %s", ji.GetURL()) // Update known instances - known, err := p.LoadKnownJIRAInstances() + known, err := store.LoadKnownJIRAInstances() if err != nil { return err } known[ji.GetURL()] = ji.GetType() - err = p.StoreKnownJIRAInstances(known) + err = store.StoreKnownJIRAInstances(known) if err != nil { return err } - p.debugf("Stored: known Jira instances: %+v", known) + store.plugin.debugf("Stored: known Jira instances: %+v", known) return nil } -func (p *Plugin) CreateInactiveCloudInstance(jiraURL string) (returnErr error) { +func (store store) CreateInactiveCloudInstance(jiraURL string) (returnErr error) { defer func() { if returnErr == nil { return } - returnErr = errors.WithMessage(returnErr, - fmt.Sprintf("failed to store new Jira Cloud instance:%s", jiraURL)) + returnErr = errors.WithMessagef(returnErr, + "failed to store new Jira Cloud instance:%s", jiraURL) }() - ji := NewJIRACloudInstance(p, jiraURL, false, + ji := NewJIRACloudInstance(store.plugin, jiraURL, false, fmt.Sprintf(`{"BaseURL": %s}`, jiraURL), &AtlassianSecurityContext{BaseURL: jiraURL}) @@ -131,15 +178,16 @@ func (p *Plugin) CreateInactiveCloudInstance(jiraURL string) (returnErr error) { } // Expire in 15 minutes - appErr := p.API.KVSetWithExpiry(hashkey(prefixJIRAInstance, ji.GetURL()), data, 15*60) + appErr := store.plugin.API.KVSetWithExpiry(hashkey(prefixJIRAInstance, + ji.GetURL()), data, 15*60) if appErr != nil { return appErr } - p.debugf("Stored: new Jira Cloud instance: %s", ji.GetURL()) + store.plugin.debugf("Stored: new Jira Cloud instance: %s", ji.GetURL()) return nil } -func (p *Plugin) StoreCurrentJIRAInstanceAndNotify(ji Instance) (returnErr error) { +func (store store) StoreCurrentJIRAInstance(ji Instance) (returnErr error) { defer func() { if returnErr == nil { return @@ -147,25 +195,15 @@ func (p *Plugin) StoreCurrentJIRAInstanceAndNotify(ji Instance) (returnErr error returnErr = errors.WithMessage(returnErr, fmt.Sprintf("failed to store current Jira instance:%s", ji.GetURL())) }() - err := p.kvSet(keyCurrentJIRAInstance, ji) + err := store.set(keyCurrentJIRAInstance, ji) if err != nil { return err } - p.debugf("Stored: current Jira instance: %s", ji.GetURL()) - - // Notify users we have installed an instance - p.API.PublishWebSocketEvent( - wSEventInstanceStatus, - map[string]interface{}{ - "instance_installed": true, - }, - &model.WebsocketBroadcast{}, - ) - + store.plugin.debugf("Stored: current Jira instance: %s", ji.GetURL()) return nil } -func (p *Plugin) DeleteJiraInstance(key string) (returnErr error) { +func (store store) DeleteJiraInstance(key string) (returnErr error) { defer func() { if returnErr == nil { return @@ -175,14 +213,14 @@ func (p *Plugin) DeleteJiraInstance(key string) (returnErr error) { }() // Delete the instance. - appErr := p.API.KVDelete(hashkey(prefixJIRAInstance, key)) + appErr := store.plugin.API.KVDelete(hashkey(prefixJIRAInstance, key)) if appErr != nil { return appErr } - p.debugf("Deleted: Jira instance: %s", key) + store.plugin.debugf("Deleted: Jira instance: %s", key) // Update known instances - known, err := p.LoadKnownJIRAInstances() + known, err := store.LoadKnownJIRAInstances() if err != nil { return err } @@ -192,30 +230,30 @@ func (p *Plugin) DeleteJiraInstance(key string) (returnErr error) { break } } - err = p.StoreKnownJIRAInstances(known) + err = store.StoreKnownJIRAInstances(known) if err != nil { return err } - p.debugf("Deleted: from known Jira instances: %s", key) + store.plugin.debugf("Deleted: from known Jira instances: %s", key) // Remove the current instance if it matches the deleted - current, err := p.LoadCurrentJIRAInstance() + current, err := store.LoadCurrentJIRAInstance() if err != nil { return err } if current.GetURL() == key { - appErr := p.API.KVDelete(keyCurrentJIRAInstance) + appErr := store.plugin.API.KVDelete(keyCurrentJIRAInstance) if appErr != nil { return appErr } - p.debugf("Deleted: current Jira instance") + store.plugin.debugf("Deleted: current Jira instance") } return nil } -func (p *Plugin) LoadCurrentJIRAInstance() (Instance, error) { - ji, err := p.loadJIRAInstance(keyCurrentJIRAInstance) +func (store store) LoadCurrentJIRAInstance() (Instance, error) { + ji, err := store.loadJIRAInstance(keyCurrentJIRAInstance) if err != nil { return nil, errors.WithMessage(err, "failed to load current Jira instance") } @@ -223,8 +261,8 @@ func (p *Plugin) LoadCurrentJIRAInstance() (Instance, error) { return ji, nil } -func (p *Plugin) LoadJIRAInstance(key string) (Instance, error) { - ji, err := p.loadJIRAInstance(hashkey(prefixJIRAInstance, key)) +func (store store) LoadJIRAInstance(key string) (Instance, error) { + ji, err := store.loadJIRAInstance(hashkey(prefixJIRAInstance, key)) if err != nil { return nil, errors.WithMessage(err, "failed to load Jira instance "+key) } @@ -232,8 +270,8 @@ func (p *Plugin) LoadJIRAInstance(key string) (Instance, error) { return ji, nil } -func (p *Plugin) loadJIRAInstance(fullkey string) (Instance, error) { - data, appErr := p.API.KVGet(fullkey) +func (store store) loadJIRAInstance(fullkey string) (Instance, error) { + data, appErr := store.plugin.API.KVGet(fullkey) if appErr != nil { return nil, appErr } @@ -255,18 +293,18 @@ func (p *Plugin) loadJIRAInstance(fullkey string) (Instance, error) { if err != nil { return nil, errors.WithMessage(err, "failed to unmarshal stored Instance "+fullkey) } - jci.Init(p) + jci.Init(store.plugin) return &jci, nil case JIRATypeServer: - jsi.Init(p) + jsi.Init(store.plugin) return &jsi, nil } return nil, errors.New(fmt.Sprintf("Jira instance %s has unsupported type: %s", fullkey, jsi.Type)) } -func (p *Plugin) StoreKnownJIRAInstances(known map[string]string) (returnErr error) { +func (store store) StoreKnownJIRAInstances(known map[string]string) (returnErr error) { defer func() { if returnErr == nil { return @@ -275,19 +313,19 @@ func (p *Plugin) StoreKnownJIRAInstances(known map[string]string) (returnErr err fmt.Sprintf("failed to store known Jira instances %+v", known)) }() - return p.kvSet(keyKnownJIRAInstances, known) + return store.set(keyKnownJIRAInstances, known) } -func (p *Plugin) LoadKnownJIRAInstances() (map[string]string, error) { +func (store store) LoadKnownJIRAInstances() (map[string]string, error) { known := map[string]string{} - err := p.kvGet(keyKnownJIRAInstances, &known) + err := store.get(keyKnownJIRAInstances, &known) if err != nil { return nil, errors.WithMessage(err, "failed to load known Jira instances") } return known, nil } -func (p *Plugin) StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JIRAUser) (returnErr error) { +func (store store) StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JIRAUser) (returnErr error) { defer func() { if returnErr == nil { return @@ -296,17 +334,17 @@ func (p *Plugin) StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JI fmt.Sprintf("failed to store user, mattermostUserId:%s, Jira user:%s", mattermostUserId, jiraUser.Name)) }() - err := p.kvSet(keyWithInstance(ji, mattermostUserId), jiraUser) + err := store.set(keyWithInstance(ji, mattermostUserId), jiraUser) if err != nil { return err } - err = p.kvSet(keyWithInstance(ji, jiraUser.Name), mattermostUserId) + err = store.set(keyWithInstance(ji, jiraUser.Name), mattermostUserId) if err != nil { return err } - p.debugf("Stored: Jira user, keys:\n\t%s (%s): %+v\n\t%s (%s): %s", + store.plugin.debugf("Stored: Jira user, keys:\n\t%s (%s): %+v\n\t%s (%s): %s", keyWithInstance(ji, mattermostUserId), mattermostUserId, jiraUser, keyWithInstance(ji, jiraUser.Name), jiraUser.Name, mattermostUserId) @@ -315,9 +353,9 @@ func (p *Plugin) StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JI var ErrUserNotFound = errors.New("user not found") -func (p *Plugin) LoadJIRAUser(ji Instance, mattermostUserId string) (JIRAUser, error) { +func (store store) LoadJIRAUser(ji Instance, mattermostUserId string) (JIRAUser, error) { jiraUser := JIRAUser{} - err := p.kvGet(keyWithInstance(ji, mattermostUserId), &jiraUser) + err := store.get(keyWithInstance(ji, mattermostUserId), &jiraUser) if err != nil { return JIRAUser{}, errors.WithMessage(err, fmt.Sprintf("failed to load Jira user for mattermostUserId:%s", mattermostUserId)) @@ -328,9 +366,9 @@ func (p *Plugin) LoadJIRAUser(ji Instance, mattermostUserId string) (JIRAUser, e return jiraUser, nil } -func (p *Plugin) LoadMattermostUserId(ji Instance, jiraUserName string) (string, error) { +func (store store) LoadMattermostUserId(ji Instance, jiraUserName string) (string, error) { mattermostUserId := "" - err := p.kvGet(keyWithInstance(ji, jiraUserName), &mattermostUserId) + err := store.get(keyWithInstance(ji, jiraUserName), &mattermostUserId) if err != nil { return "", errors.WithMessage(err, "failed to load Mattermost user ID for Jira user: "+jiraUserName) @@ -341,7 +379,7 @@ func (p *Plugin) LoadMattermostUserId(ji Instance, jiraUserName string) (string, return mattermostUserId, nil } -func (p *Plugin) DeleteUserInfo(ji Instance, mattermostUserId string) (returnErr error) { +func (store store) DeleteUserInfo(ji Instance, mattermostUserId string) (returnErr error) { defer func() { if returnErr == nil { return @@ -350,28 +388,28 @@ func (p *Plugin) DeleteUserInfo(ji Instance, mattermostUserId string) (returnErr fmt.Sprintf("failed to delete user, mattermostUserId:%s", mattermostUserId)) }() - jiraUser, err := p.LoadJIRAUser(ji, mattermostUserId) + jiraUser, err := store.LoadJIRAUser(ji, mattermostUserId) if err != nil { return err } - appErr := p.API.KVDelete(keyWithInstance(ji, mattermostUserId)) + appErr := store.plugin.API.KVDelete(keyWithInstance(ji, mattermostUserId)) if appErr != nil { return appErr } - appErr = p.API.KVDelete(keyWithInstance(ji, jiraUser.Name)) + appErr = store.plugin.API.KVDelete(keyWithInstance(ji, jiraUser.Name)) if appErr != nil { return appErr } - p.debugf("Deleted: user, keys: %s(%s), %s(%s)", + store.plugin.debugf("Deleted: user, keys: %s(%s), %s(%s)", mattermostUserId, keyWithInstance(ji, mattermostUserId), jiraUser.Name, keyWithInstance(ji, jiraUser.Name)) return nil } -func (p *Plugin) EnsureAuthTokenEncryptSecret() (secret []byte, returnErr error) { +func (store store) EnsureAuthTokenEncryptSecret() (secret []byte, returnErr error) { defer func() { if returnErr == nil { return @@ -380,7 +418,7 @@ func (p *Plugin) EnsureAuthTokenEncryptSecret() (secret []byte, returnErr error) }() // nil, nil == NOT_FOUND, if we don't already have a key, try to generate one. - secret, appErr := p.API.KVGet(keyTokenSecret) + secret, appErr := store.plugin.API.KVGet(keyTokenSecret) if appErr != nil { return nil, appErr } @@ -392,18 +430,18 @@ func (p *Plugin) EnsureAuthTokenEncryptSecret() (secret []byte, returnErr error) return nil, err } - appErr = p.API.KVSet(keyTokenSecret, newSecret) + appErr = store.plugin.API.KVSet(keyTokenSecret, newSecret) if appErr != nil { return nil, appErr } secret = newSecret - p.debugf("Stored: auth token secret") + store.plugin.debugf("Stored: auth token secret") } // If we weren't able to save a new key above, another server must have beat us to it. Get the // key from the database, and if that fails, error out. if secret == nil { - secret, appErr = p.API.KVGet(keyTokenSecret) + secret, appErr = store.plugin.API.KVGet(keyTokenSecret) if appErr != nil { return nil, appErr } @@ -412,7 +450,7 @@ func (p *Plugin) EnsureAuthTokenEncryptSecret() (secret []byte, returnErr error) return secret, nil } -func (p *Plugin) EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) { +func (store store) EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) { defer func() { if returnErr == nil { return @@ -420,7 +458,7 @@ func (p *Plugin) EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) { returnErr = errors.WithMessage(returnErr, "failed to ensure RSA key") }() - appErr := p.kvGet(keyRSAKey, &rsaKey) + appErr := store.get(keyRSAKey, &rsaKey) if appErr != nil { return nil, appErr } @@ -431,18 +469,18 @@ func (p *Plugin) EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) { return nil, err } - appErr = p.kvSet(keyRSAKey, newRSAKey) + appErr = store.set(keyRSAKey, newRSAKey) if appErr != nil { return nil, appErr } rsaKey = newRSAKey - p.debugf("Stored: RSA key") + store.plugin.debugf("Stored: RSA key") } // If we weren't able to save a new key above, another server must have beat us to it. Get the // key from the database, and if that fails, error out. if rsaKey == nil { - appErr = p.kvGet(keyRSAKey, &rsaKey) + appErr = store.get(keyRSAKey, &rsaKey) if appErr != nil { return nil, appErr } @@ -451,25 +489,26 @@ func (p *Plugin) EnsureRSAKey() (rsaKey *rsa.PrivateKey, returnErr error) { return rsaKey, nil } -func (p *Plugin) StoreOneTimeSecret(token, secret string) error { +func (store store) StoreOneTimeSecret(token, secret string) error { // Expire in 15 minutes - appErr := p.API.KVSetWithExpiry(hashkey(prefixOneTimeSecret, token), []byte(secret), 15*60) + appErr := store.plugin.API.KVSetWithExpiry( + hashkey(prefixOneTimeSecret, token), []byte(secret), 15*60) if appErr != nil { return errors.WithMessage(appErr, "failed to store one-ttime secret "+token) } return nil } -func (p *Plugin) LoadOneTimeSecret(token string) (string, error) { - b, appErr := p.API.KVGet(hashkey(prefixOneTimeSecret, token)) +func (store store) LoadOneTimeSecret(token string) (string, error) { + b, appErr := store.plugin.API.KVGet(hashkey(prefixOneTimeSecret, token)) if appErr != nil { return "", errors.WithMessage(appErr, "failed to load one-time secret "+token) } return string(b), nil } -func (p *Plugin) DeleteOneTimeSecret(token string) error { - appErr := p.API.KVDelete(hashkey(prefixOneTimeSecret, token)) +func (store store) DeleteOneTimeSecret(token string) error { + appErr := store.plugin.API.KVDelete(hashkey(prefixOneTimeSecret, token)) if appErr != nil { return errors.WithMessage(appErr, "failed to delete one-time secret "+token) } diff --git a/server/kv_mock_test.go b/server/kv_mock_test.go new file mode 100644 index 000000000..4a769ab0e --- /dev/null +++ b/server/kv_mock_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "github.com/andygrunwald/go-jira" + "github.com/pkg/errors" +) + +type jiraTestInstance struct { + JIRAInstance +} + +var _ Instance = (*jiraTestInstance)(nil) + +func (jti jiraTestInstance) GetURL() string { + return "http://jiraTestInstanceURL.some" +} +func (jti jiraTestInstance) GetMattermostKey() string { + return "jiraTestInstanceMattermostKey" +} +func (jti jiraTestInstance) GetDisplayDetails() map[string]string { + return map[string]string{} +} +func (jti jiraTestInstance) GetUserConnectURL(mattermostUserId string) (string, error) { + return "http://jiraTestInstanceUserConnectURL.some", nil +} +func (jti jiraTestInstance) GetJIRAClient(jiraUser JIRAUser) (*jira.Client, error) { + return nil, errors.New("not implemented") +} + +type mockCurrentInstanceStore struct { + plugin *Plugin +} + +func (store mockCurrentInstanceStore) StoreCurrentJIRAInstance(ji Instance) error { + return nil +} +func (store mockCurrentInstanceStore) LoadCurrentJIRAInstance() (Instance, error) { + return &jiraTestInstance{ + JIRAInstance: *NewJIRAInstance(store.plugin, "test", "jiraTestInstanceKey"), + }, nil +} + +type mockUserStore struct{} + +func (store mockUserStore) StoreUserInfo(ji Instance, mattermostUserId string, jiraUser JIRAUser) error { + return nil +} +func (store mockUserStore) LoadJIRAUser(ji Instance, mattermostUserId string) (JIRAUser, error) { + return JIRAUser{}, nil +} +func (store mockUserStore) LoadMattermostUserId(ji Instance, jiraUserName string) (string, error) { + return "testMattermostUserId012345", nil +} +func (store mockUserStore) DeleteUserInfo(ji Instance, mattermostUserId string) error { + return nil +} diff --git a/server/plugin.go b/server/plugin.go index 5e071e1f9..934fcdcf6 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -44,6 +44,12 @@ type Plugin struct { conf config confLock sync.RWMutex + currentInstanceStore CurrentInstanceStore + instanceStore InstanceStore + userStore UserStore + otsStore OTSStore + secretsStore SecretsStore + // Generated once, then cached in the database, and here deserialized RSAKey *rsa.PrivateKey `json:",omitempty"` @@ -88,6 +94,13 @@ func (p *Plugin) OnActivate() error { return errors.WithMessage(appErr, fmt.Sprintf("OnActivate: unable to find user: %s", conf.UserName)) } + store := NewStore(p) + p.currentInstanceStore = store + p.instanceStore = store + p.userStore = store + p.secretsStore = store + p.otsStore = store + dir := filepath.Join(*(p.API.GetConfig().PluginSettings.Directory), manifest.Id, "server", "dist", "templates") templates, err := p.loadTemplates(dir) if err != nil { diff --git a/server/plugin_test.go b/server/plugin_test.go index 52f59852b..e5125b2ef 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -186,6 +186,7 @@ func TestPlugin(t *testing.T) { conf.UserName = tc.Configuration.UserName }) p.SetAPI(api) + p.currentInstanceStore = mockCurrentInstanceStore{} w := httptest.NewRecorder() p.ServeHTTP(&plugin.Context{}, w, tc.Request) diff --git a/server/settings.go b/server/settings.go index bbd63e591..e75f838b1 100644 --- a/server/settings.go +++ b/server/settings.go @@ -28,13 +28,13 @@ func (p *Plugin) settingsNotifications(ji Instance, mattermostUserId string, jir jiraUser.Settings = &UserSettings{} } jiraUser.Settings.Notifications = value - if err := p.StoreUserInfo(ji, mattermostUserId, jiraUser); err != nil { + if err := p.userStore.StoreUserInfo(ji, mattermostUserId, jiraUser); err != nil { p.errorf("settingsNotifications, err: %v", err) responsef("Could not store new settings. Please contact your system administrator. error: %v", err) } // send back the actual value - updatedJiraUser, err := p.LoadJIRAUser(ji, mattermostUserId) + updatedJiraUser, err := p.userStore.LoadJIRAUser(ji, mattermostUserId) if err != nil { return responsef("Your username is not connected to Jira. Please type `jira connect`. %v", err) } diff --git a/server/subscribe.go b/server/subscribe.go index 62c30ba43..8ac5f5e19 100644 --- a/server/subscribe.go +++ b/server/subscribe.go @@ -104,13 +104,13 @@ func (p *Plugin) getUserID() (string, error) { return user.Id, nil } -func (p *Plugin) getChannelsSubscribed(webhook *parsedJIRAWebhook) ([]string, error) { +func (p *Plugin) getChannelsSubscribed(jwh *JiraWebhook) ([]string, error) { subs, err := p.getSubscriptions() if err != nil { return nil, err } - subIds := subs.Channel.IdByEvent[webhook.WebhookEvent] + subIds := subs.Channel.IdByEvent[jwh.WebhookEvent] channelIds := []string{} for _, subId := range subIds { @@ -126,7 +126,7 @@ func (p *Plugin) getChannelsSubscribed(webhook *parsedJIRAWebhook) ([]string, er case "event": found := false for _, acceptableEvent := range acceptableValues { - if acceptableEvent == webhook.WebhookEvent { + if acceptableEvent == jwh.WebhookEvent { found = true break } @@ -138,7 +138,7 @@ func (p *Plugin) getChannelsSubscribed(webhook *parsedJIRAWebhook) ([]string, er case "project": found := false for _, acceptableProject := range acceptableValues { - if acceptableProject == webhook.Issue.Fields.Project.Key { + if acceptableProject == jwh.Issue.Fields.Project.Key { found = true break } @@ -150,7 +150,7 @@ func (p *Plugin) getChannelsSubscribed(webhook *parsedJIRAWebhook) ([]string, er case "issue_type": found := false for _, acceptableIssueType := range acceptableValues { - if acceptableIssueType == webhook.Issue.Fields.IssueType.Id { + if acceptableIssueType == jwh.Issue.Fields.Type.ID { found = true break } @@ -313,17 +313,20 @@ func httpSubscribeWebhook(p *Plugin, w http.ResponseWriter, r *http.Request) (in return http.StatusMethodNotAllowed, fmt.Errorf("Request: " + r.Method + " is not allowed, must be POST") } - cfg := p.getConfig() if cfg.Secret == "" || cfg.UserName == "" { return http.StatusForbidden, fmt.Errorf("JIRA plugin not configured correctly; must provide Secret and UserName") } + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() + if err != nil { + return http.StatusInternalServerError, err + } if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(cfg.Secret)) != 1 { return http.StatusForbidden, fmt.Errorf("Request URL: secret did not match") } - parsed, err := parse(r.Body, nil) + wh, jwh, err := ParseWebhook(r.Body) if err != nil { return http.StatusInternalServerError, err } @@ -333,37 +336,21 @@ func httpSubscribeWebhook(p *Plugin, w http.ResponseWriter, r *http.Request) (in return http.StatusInternalServerError, err } - channelIds, err := p.getChannelsSubscribed(parsed) + channelIds, err := p.getChannelsSubscribed(jwh) if err != nil { return http.StatusInternalServerError, err } - attachment := newSlackAttachment(parsed) - for _, channelId := range channelIds { - post := &model.Post{ - ChannelId: channelId, - UserId: botUserId, - } - - model.ParseSlackAttachment(post, []*model.SlackAttachment{attachment}) - - if err != nil { - return http.StatusBadGateway, err - } - _, appErr := p.API.CreatePost(post) - if appErr != nil { - return appErr.StatusCode, fmt.Errorf(appErr.Message) + if _, status, err1 := wh.PostToChannel(p, channelId, botUserId); err1 != nil { + return status, err1 } } - // Notify any affected users using a direct channel - err = p.handleNotifications(parsed) + _, status, err := wh.PostNotifications(p, ji) if err != nil { - p.errorf("httpSubscribeWebhook, handleNotifications: %v", err) - return http.StatusBadRequest, err + return status, err } - return http.StatusOK, nil } diff --git a/server/user.go b/server/user.go index 0452551f9..a4db3829a 100644 --- a/server/user.go +++ b/server/user.go @@ -48,7 +48,7 @@ func httpUserConnect(ji Instance, w http.ResponseWriter, r *http.Request) (int, } // Users shouldn't be able to make multiple connections. - if jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mattermostUserId); err == nil && len(jiraUser.Key) != 0 { + if jiraUser, err := ji.GetPlugin().userStore.LoadJIRAUser(ji, mattermostUserId); err == nil && len(jiraUser.Key) != 0 { return http.StatusBadRequest, errors.New("Already connected to a JIRA account. Please use /jira disconnect to disconnect.") } @@ -112,8 +112,8 @@ func httpAPIGetUserInfo(p *Plugin, w http.ResponseWriter, r *http.Request) (int, } resp := UserInfo{} - if ji, err := p.LoadCurrentJIRAInstance(); err == nil { - if jiraUser, err := ji.GetPlugin().LoadJIRAUser(ji, mattermostUserId); err == nil { + if ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance(); err == nil { + if jiraUser, err := p.userStore.LoadJIRAUser(ji, mattermostUserId); err == nil { resp = UserInfo{ JIRAUser: jiraUser, InstanceInstalled: true, @@ -132,7 +132,7 @@ func httpAPIGetUserInfo(p *Plugin, w http.ResponseWriter, r *http.Request) (int, } func (p *Plugin) StoreUserInfoNotify(ji Instance, mattermostUserId string, jiraUser JIRAUser) error { - err := p.StoreUserInfo(ji, mattermostUserId, jiraUser) + err := p.userStore.StoreUserInfo(ji, mattermostUserId, jiraUser) if err != nil { return err } @@ -151,7 +151,7 @@ func (p *Plugin) StoreUserInfoNotify(ji Instance, mattermostUserId string, jiraU } func (p *Plugin) DeleteUserInfoNotify(ji Instance, mattermostUserId string) error { - err := p.DeleteUserInfo(ji, mattermostUserId) + err := p.userStore.DeleteUserInfo(ji, mattermostUserId) if err != nil { return err } diff --git a/server/user_cloud.go b/server/user_cloud.go index 5574cd702..6d5a72791 100644 --- a/server/user_cloud.go +++ b/server/user_cloud.go @@ -84,11 +84,11 @@ func httpACUserInteractive(jci *jiraCloudInstance, w http.ResponseWriter, r *htt switch r.URL.Path { case routeACUserConnected: value := "" - value, err = jci.Plugin.LoadOneTimeSecret(secret) + value, err = jci.Plugin.otsStore.LoadOneTimeSecret(secret) if err != nil { return http.StatusUnauthorized, err } - err = jci.Plugin.DeleteOneTimeSecret(secret) + err = jci.Plugin.otsStore.DeleteOneTimeSecret(secret) if err != nil { return http.StatusInternalServerError, err } diff --git a/server/user_server.go b/server/user_server.go index bee78b201..bbf47e6f1 100644 --- a/server/user_server.go +++ b/server/user_server.go @@ -22,11 +22,11 @@ func httpOAuth1Complete(jsi *jiraServerInstance, w http.ResponseWriter, r *http. errors.WithMessage(err, "failed to parse callback request from Jira") } - requestSecret, err := jsi.Plugin.LoadOneTimeSecret(requestToken) + requestSecret, err := jsi.Plugin.otsStore.LoadOneTimeSecret(requestToken) if err != nil { return http.StatusInternalServerError, err } - err = jsi.Plugin.DeleteOneTimeSecret(requestToken) + err = jsi.Plugin.otsStore.DeleteOneTimeSecret(requestToken) if err != nil { return http.StatusInternalServerError, err } @@ -118,7 +118,7 @@ func httpOAuth1PublicKey(p *Plugin, w http.ResponseWriter, r *http.Request) (int } func publicKeyString(p *Plugin) ([]byte, error) { - rsaKey, err := p.EnsureRSAKey() + rsaKey, err := p.secretsStore.EnsureRSAKey() if err != nil { return nil, err } diff --git a/server/utils.go b/server/utils.go index bdd174e1d..f8111d187 100644 --- a/server/utils.go +++ b/server/utils.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "net/url" "regexp" "github.com/andygrunwald/go-jira" @@ -11,7 +10,8 @@ import ( "github.com/mattermost/mattermost-server/model" ) -func (p *Plugin) CreateBotDMPost(ji Instance, userId, message, postType string) (returnErr error) { +func (p *Plugin) CreateBotDMPost(ji Instance, userId, message, + postType string) (post *model.Post, returnErr error) { defer func() { if returnErr != nil { returnErr = errors.WithMessage(returnErr, @@ -20,22 +20,22 @@ func (p *Plugin) CreateBotDMPost(ji Instance, userId, message, postType string) }() // Don't send DMs to users who have turned off notifications - jiraUser, err := p.LoadJIRAUser(ji, userId) + jiraUser, err := p.userStore.LoadJIRAUser(ji, userId) if err != nil { // not connected to Jira, so no need to send a DM, and no need to report an error - return nil + return nil, nil } - if !jiraUser.Settings.Notifications { - return nil + if jiraUser.Settings == nil || !jiraUser.Settings.Notifications { + return nil, nil } conf := p.getConfig() channel, appErr := p.API.GetDirectChannel(userId, conf.botUserID) if appErr != nil { - return appErr + return nil, appErr } - post := &model.Post{ + post = &model.Post{ UserId: conf.botUserID, ChannelId: channel.Id, Message: message, @@ -49,9 +49,25 @@ func (p *Plugin) CreateBotDMPost(ji Instance, userId, message, postType string) _, appErr = p.API.CreatePost(post) if appErr != nil { - return appErr + return nil, appErr } + return post, nil +} + +func (p *Plugin) StoreCurrentJIRAInstanceAndNotify(ji Instance) error { + appErr := p.currentInstanceStore.StoreCurrentJIRAInstance(ji) + if appErr != nil { + return appErr + } + // Notify users we have installed an instance + p.API.PublishWebSocketEvent( + wSEventInstanceStatus, + map[string]interface{}{ + "instance_installed": true, + }, + &model.WebsocketBroadcast{}, + ) return nil } @@ -101,12 +117,3 @@ func parseJIRAIssuesFromText(text string, keys []string) []string { return issues } - -func getIssueURL(i *JIRAWebhookIssue) string { - u, _ := url.Parse(i.Self) - return u.Scheme + "://" + u.Host + "/browse/" + i.Key -} - -func getUserURL(issue *JIRAWebhookIssue, user *jira.User) string { - return user.Self -} diff --git a/server/webhook.go b/server/webhook.go index 07a65c1fd..ce6d74b04 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -1,413 +1,106 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License for license information. +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. package main import ( - "crypto/subtle" - "encoding/json" "fmt" - "github.com/pkg/errors" - "io" - "io/ioutil" "net/http" "net/url" - "strings" - "github.com/andygrunwald/go-jira" + "github.com/pkg/errors" "github.com/mattermost/mattermost-server/model" ) -const ( - jiraCommentPostType = "custom_jira_comment" - jiraMentionPostType = "custom_jira_mention" -) - -type JIRAWebhookIssue struct { - Self string - Key string - Fields struct { - Assignee *jira.User - Reporter *jira.User - Summary string - Description string - Priority *struct { - Id string - Name string - IconURL string - } - IssueType struct { - Id string - Name string - IconURL string - } - Resolution *struct { - Id string - } - Status struct { - Id string - } - Labels []string - Project struct { - Key string - } - } -} - -type JIRAWebhook struct { - WebhookEvent string - Issue JIRAWebhookIssue - User jira.User - Comment struct { - Body string - UpdateAuthor jira.User - } - ChangeLog struct { - Items []struct { - From string - FromString string - To string - ToString string - Field string - } - } - IssueEventTypeName string `json:"issue_event_type_name"` -} - -type parsedJIRAWebhook struct { - *JIRAWebhook - RawJSON string - headline string - details string - text string - style string - authorDisplayName string - authorUsername string - authorURL string - assigneeUsername string - issueKey string - issueURL string -} - -func httpWebhook(p *Plugin, w http.ResponseWriter, r *http.Request) (int, error) { - if r.Method != http.MethodPost { - return http.StatusMethodNotAllowed, - fmt.Errorf("Request: " + r.Method + " is not allowed, must be POST") - } - // TODO add JWT support - cfg := p.getConfig() - if cfg.Secret == "" || cfg.UserName == "" { - return http.StatusForbidden, fmt.Errorf("Jira plugin not configured correctly; must provide Secret and UserName") - } - - secret := r.FormValue("secret") - for { - if subtle.ConstantTimeCompare([]byte(secret), []byte(cfg.Secret)) == 1 { - break - } - - unescaped, _ := url.QueryUnescape(secret) - if unescaped == secret { - return http.StatusForbidden, - fmt.Errorf("Request URL: secret did not match") - } - secret = unescaped - } - - parsed, err := parse(r.Body, nil) - if err != nil { - return http.StatusBadRequest, err - } - - // Post the event to the subscribed channel - statusCode, err := p.postEvent(r, cfg, parsed) - if err != nil { - return statusCode, err - } - - // Notify any affected users using a direct channel - err = p.handleNotifications(parsed) - if err != nil { - p.errorf("httpWebhook, handleNotifications: %v", err) - return http.StatusBadRequest, err - } - - return http.StatusOK, nil +type Webhook interface { + EventMask() uint64 + PostToChannel(p *Plugin, channelId, fromUserId string) (*model.Post, int, error) + PostNotifications(p *Plugin, ji Instance) ([]*model.Post, int, error) } -func (w *JIRAWebhook) jiraURL() string { - pos := strings.LastIndex(w.Issue.Self, "/rest/api") - if pos < 0 { - return "" - } - return w.Issue.Self[:pos] +type webhook struct { + *JiraWebhook + eventMask uint64 + headline string + text string + fields []*model.SlackAttachmentField + notifications []webhookNotification } -func parse(in io.Reader, linkf func(w *JIRAWebhook) string) (*parsedJIRAWebhook, error) { - bb, err := ioutil.ReadAll(in) - if err != nil { - return nil, err - } - - webhook := JIRAWebhook{} - err = json.Unmarshal(bb, &webhook) - if err != nil { - return nil, err - } - if webhook.WebhookEvent == "" { - return nil, fmt.Errorf("No webhook event") - } - - parsed := parsedJIRAWebhook{ - JIRAWebhook: &webhook, - } - parsed.RawJSON = string(bb) - if linkf == nil { - linkf = func(w *JIRAWebhook) string { - return parsed.mdIssueLink() - } - } - - headline := "" - user := &parsed.User - parsed.style = mdUpdateStyle - issue := parsed.mdIssueType() + " " + linkf(parsed.JIRAWebhook) - switch parsed.WebhookEvent { - case "jira:issue_created": - parsed.style = mdRootStyle - headline = fmt.Sprintf("created %v", issue) - parsed.details = parsed.mdIssueCreatedDetails() - parsed.text = parsed.mdIssueDescription() - case "jira:issue_deleted": - headline = fmt.Sprintf("deleted %v", issue) - case "jira:issue_updated": - switch parsed.IssueEventTypeName { - case "issue_assigned": - headline = fmt.Sprintf("assigned %v to %v", issue, parsed.mdIssueAssignee()) - - case "issue_updated", "issue_generic": - // text summary, description, updated priority, status, etc. - headline, parsed.text = parsed.fromChangeLog(issue) - } - case "comment_deleted": - user = &parsed.Comment.UpdateAuthor - headline = fmt.Sprintf("removed a comment from %v", issue) - - case "comment_updated": - user = &parsed.Comment.UpdateAuthor - headline = fmt.Sprintf("edited a comment in %v", issue) - parsed.text = truncate(parsed.Comment.Body, 3000) - - case "comment_created": - user = &parsed.Comment.UpdateAuthor - headline = fmt.Sprintf("commented on %v", issue) - parsed.text = truncate(parsed.Comment.Body, 3000) - } - if headline == "" { - return nil, fmt.Errorf("Unsupported webhook data: %v", parsed.WebhookEvent) - } - parsed.headline = fmt.Sprintf("%v %v %v", mdUser(user), headline, parsed.mdIssueHashtags()) - - parsed.authorDisplayName = user.DisplayName - parsed.authorUsername = user.Name - parsed.authorURL = getUserURL(&parsed.Issue, user) - if parsed.Issue.Fields.Assignee != nil { - parsed.assigneeUsername = parsed.Issue.Fields.Assignee.Name - } - parsed.issueKey = parsed.Issue.Key - parsed.issueURL = getIssueURL(&parsed.Issue) - - return &parsed, nil +type webhookNotification struct { + jiraUsername string + message string + postType string } -func (p *parsedJIRAWebhook) fromChangeLog(issue string) (string, string) { - for _, item := range p.ChangeLog.Items { - to := item.ToString - from := item.FromString - switch { - case item.Field == "resolution" && to == "" && from != "": - return fmt.Sprintf("reopened %v", issue), "" - - case item.Field == "resolution" && to != "" && from == "": - return fmt.Sprintf("resolved %v", issue), "" - - case item.Field == "status" && to == "Backlog": - return fmt.Sprintf("moved %v to backlog", issue), "" - - case item.Field == "status" && to == "In Progress": - return fmt.Sprintf("started working on %v", issue), "" - - case item.Field == "status" && to == "Selected for Development": - return fmt.Sprintf("selected %v for development", issue), "" - - case item.Field == "priority" && item.From > item.To: - return fmt.Sprintf("raised priority of %v to %v", issue, to), "" - - case item.Field == "priority" && item.From < item.To: - return fmt.Sprintf("lowered priority of %v to %v", issue, to), "" - - case item.Field == "summary": - return fmt.Sprintf("renamed %v to %v", issue, p.mdIssueSummary()), "" - - case item.Field == "description": - return fmt.Sprintf("edited description of %v", issue), - p.mdIssueDescription() - - case item.Field == "Sprint" && len(to) > 0: - return fmt.Sprintf("moved %v to %v", issue, to), "" - - case item.Field == "Rank" && len(to) > 0: - return fmt.Sprintf("%v %v", strings.ToLower(to), issue), "" - - case item.Field == "Attachment": - return fmt.Sprintf("%v %v", mdAddRemove(from, to, "attached", "removed attachments"), issue), "" - - case item.Field == "labels": - return fmt.Sprintf("%v %v", mdAddRemove(from, to, "added labels", "removed labels"), issue), "" - } - } - return "", "" +func (wh *webhook) EventMask() uint64 { + return wh.eventMask } -// postEvent posts the event to the channel that subscribed to it -func (p *Plugin) postEvent(r *http.Request, cfg config, parsed *parsedJIRAWebhook) (int, error) { - teamName := r.FormValue("team") - if teamName == "" { - return http.StatusBadRequest, - fmt.Errorf("Request URL: team is empty") +func (wh webhook) PostToChannel(p *Plugin, channelId, fromUserId string) (*model.Post, int, error) { + if wh.headline == "" { + return nil, http.StatusBadRequest, errors.Errorf("unsupported webhook") } - channelId := r.FormValue("channel") - if channelId == "" { - return http.StatusBadRequest, - fmt.Errorf("Request URL: channel is empty") - } - - user, appErr := p.API.GetUserByUsername(cfg.UserName) - if appErr != nil { - return appErr.StatusCode, fmt.Errorf(appErr.Message) - } - - channel, appErr := p.API.GetChannelByNameForTeamName(teamName, channelId, false) - if appErr != nil { - return appErr.StatusCode, fmt.Errorf(appErr.Message) - } - - initPost := AsSlackAttachment(parsed) - post := &model.Post{ - ChannelId: channel.Id, - UserId: user.Id, - Props: map[string]interface{}{ - "from_webhook": "true", - "use_user_icon": "true", - }, - } - initPost(post) - - _, appErr = p.API.CreatePost(post) + ChannelId: channelId, + UserId: fromUserId, + // Props: map[string]interface{}{ + // "from_webhook": "true", + // "use_user_icon": "true", + // }, + } + if wh.text != "" || len(wh.fields) != 0 { + model.ParseSlackAttachment(post, []*model.SlackAttachment{ + { + // TODO is this supposed to be themed? + Color: "#95b7d0", + Fallback: wh.headline, + Pretext: wh.headline, + Text: wh.text, + Fields: wh.fields, + }, + }) + } else { + post.Message = wh.headline + } + + _, appErr := p.API.CreatePost(post) if appErr != nil { - return appErr.StatusCode, fmt.Errorf(appErr.Message) + return nil, appErr.StatusCode, appErr } - return http.StatusOK, nil + return post, http.StatusOK, nil } -// handleNotifications notifies users involved in the event, if they've enabled notifications -func (p *Plugin) handleNotifications(parsed *parsedJIRAWebhook) error { - // This bothers me, to do this for every webhook event... - ji, err := p.LoadCurrentJIRAInstance() - if err != nil { - // It won't break anything if we can't find the Jira Instance here -- we just can't notify anyone. - return nil - // Alternative: - //return errors.Errorf("Failed to load current Jira instance: %v", err) - } - - switch parsed.JIRAWebhook.WebhookEvent { - case "jira:issue_updated", "jira:issue_created": - return p.handleIssueUpdatedNotifications(ji, parsed) - case "comment_created": - return p.handleCommentCreatedNotifications(ji, parsed) - default: - return nil +func (wh *webhook) PostNotifications(p *Plugin, ji Instance) ([]*model.Post, int, error) { + posts := []*model.Post{} + if len(wh.notifications) == 0 { + return nil, http.StatusOK, nil } -} - -func (p *Plugin) handleIssueUpdatedNotifications(ji Instance, parsed *parsedJIRAWebhook) error { - for _, change := range parsed.ChangeLog.Items { - if change.Field != "assignee" || change.ToString == "" { - return nil - } - - if parsed.assigneeUsername == "" { - return nil - } - - mattermostUserId, err := p.LoadMattermostUserId(ji, parsed.assigneeUsername) + for _, notification := range wh.notifications { + mattermostUserId, err := p.userStore.LoadMattermostUserId( + ji, notification.jiraUsername) if err != nil { - return err + return nil, http.StatusOK, nil } - message := "[%s](%s) assigned you to [%s](%s)" - err = p.CreateBotDMPost(ji, mattermostUserId, fmt.Sprintf(message, parsed.authorDisplayName, parsed.authorURL, parsed.issueKey, parsed.issueURL), "custom_jira_assigned") + post, err := ji.GetPlugin().CreateBotDMPost(ji, mattermostUserId, notification.message, notification.postType) if err != nil { - return errors.Errorf("handleIssueUpdatedNotification failed: %v", err) + return nil, http.StatusInternalServerError, errors.WithMessage(err, "failed to create notification post") } + posts = append(posts, post) } - return nil + return posts, http.StatusOK, nil } -func (p *Plugin) handleCommentCreatedNotifications(ji Instance, parsed *parsedJIRAWebhook) error { - if parsed.authorUsername == "" { - return nil - } - - for _, u := range parseJIRAUsernamesFromText(parsed.Comment.Body) { - // don't mention the author of the text - if u == parsed.authorUsername { - continue - } - // assignee gets a special notice - if u == parsed.assigneeUsername { - continue - } - - mattermostUserId, err := p.LoadMattermostUserId(ji, u) - if err != nil { - p.errorf("handleCommentCreatedNotifications, LoadMattermostUserId: %v", err) - continue - } - - err = p.CreateBotDMPost(ji, mattermostUserId, - fmt.Sprintf("[%s](%s) mentioned you on [%s](%s):\n>%s", - parsed.authorDisplayName, parsed.authorURL, parsed.issueKey, parsed.issueURL, parsed.text), - jiraMentionPostType) - if err != nil { - p.errorf("handleCommentCreatedNotifications, CreateBotDMPost: %v", err) - continue - } +func newWebhook(jwh *JiraWebhook, eventMask uint64, format string, args ...interface{}) *webhook { + return &webhook{ + JiraWebhook: jwh, + eventMask: eventMask, + headline: jwh.mdUser() + " " + fmt.Sprintf(format, args...) + " " + jwh.mdKeyLink(), } - - if parsed.assigneeUsername == parsed.authorUsername { - return nil - } - - mattermostUserId, err := p.LoadMattermostUserId(ji, parsed.assigneeUsername) - if err != nil { - return err - } - - err = p.CreateBotDMPost(ji, mattermostUserId, - fmt.Sprintf("[%s](%s) commented on [%s](%s):\n>%s", - parsed.authorDisplayName, parsed.authorURL, parsed.issueKey, parsed.issueURL, parsed.text), - jiraCommentPostType) - if err != nil { - return errors.Errorf("handleCommentCreatedNotifications, CreateBotDMPost: %v", err) - } - - return nil } func (p *Plugin) GetWebhookURL(teamId, channelId string) (string, error) { diff --git a/server/webhook_http.go b/server/webhook_http.go new file mode 100644 index 000000000..f3d9d93eb --- /dev/null +++ b/server/webhook_http.go @@ -0,0 +1,150 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "crypto/subtle" + "fmt" + "math" + "net/http" + "net/url" + + "github.com/pkg/errors" +) + +const ( + PostTypeComment = "custom_jira_comment" + PostTypeMention = "custom_jira_mention" + PostTypeAssigned = "custom_jira_assigned" +) + +const ( + eventCreated = uint64(1 << iota) + eventCreatedComment + eventDeleted + eventDeletedComment + eventDeletedUnresolved + eventUpdatedAssignee + eventUpdatedAttachment + eventUpdatedComment + eventUpdatedDescription + eventUpdatedLabels + eventUpdatedPriority + eventUpdatedRank + eventUpdatedReopened + eventUpdatedResolved + eventUpdatedSprint + eventUpdatedStatus + eventUpdatedSummary +) + +const maskLegacy = eventCreated | + eventUpdatedReopened | + eventUpdatedResolved | + eventDeletedUnresolved + +const maskComments = eventCreatedComment | + eventDeletedComment | + eventUpdatedComment + +const maskDefault = maskLegacy | + eventUpdatedAssignee | + maskComments + +const maskAll = math.MaxUint64 + +// The keys listed here can be used in the Jira webhook URL to control what events +// are posted to Mattermost. A matching parameter with a non-empty value must +// be added to turn on the event display. +var eventParamMasks = map[string]uint64{ + "updated_attachment": eventUpdatedAttachment, // updated attachments + "updated_description": eventUpdatedDescription, // issue description edited + "updated_labels": eventUpdatedLabels, // updated labels + "updated_prioity": eventUpdatedPriority, // changes in priority + "updated_rank": eventUpdatedRank, // ranked higher or lower + "updated_sprint": eventUpdatedSprint, // assigned to a different sprint + "updated_status": eventUpdatedStatus, // transitions like Done, In Progress + "updated_summary": eventUpdatedSummary, // issue renamed + "updated_all": maskAll, // all events +} + +func httpWebhook(p *Plugin, w http.ResponseWriter, r *http.Request) (int, error) { + // Validate the request and extract params + if r.Method != http.MethodPost { + return http.StatusMethodNotAllowed, + fmt.Errorf("Request: " + r.Method + " is not allowed, must be POST") + } + cfg := p.getConfig() + if cfg.Secret == "" || cfg.UserName == "" { + return http.StatusForbidden, fmt.Errorf("Jira plugin not configured correctly; must provide Secret and UserName") + } + secret := r.FormValue("secret") + // secret may be URL-escaped, potentially mroe than once. Loop until there + // are no % escapes left. + for { + if subtle.ConstantTimeCompare([]byte(secret), []byte(cfg.Secret)) == 1 { + break + } + + unescaped, _ := url.QueryUnescape(secret) + if unescaped == secret { + return http.StatusForbidden, + errors.New("Request URL: secret did not match") + } + secret = unescaped + } + teamName := r.FormValue("team") + if teamName == "" { + return http.StatusBadRequest, + errors.New("Request URL: no team name found") + } + channelName := r.FormValue("channel") + if channelName == "" { + return http.StatusBadRequest, + errors.New("Request URL: no channel name found") + } + eventMask := maskDefault + for key, paramMask := range eventParamMasks { + if r.FormValue(key) == "" { + continue + } + eventMask = eventMask | paramMask + } + + botUser, appErr := p.API.GetUserByUsername(cfg.UserName) + if appErr != nil { + return appErr.StatusCode, appErr + } + channel, appErr := p.API.GetChannelByNameForTeamName(teamName, channelName, false) + if appErr != nil { + return appErr.StatusCode, appErr + } + + wh, _, err := ParseWebhook(r.Body) + if err != nil { + return http.StatusBadRequest, err + } + + ji, err := p.currentInstanceStore.LoadCurrentJIRAInstance() + if err != nil { + return http.StatusInternalServerError, err + } + wh.PostNotifications(p, ji) + if err != nil { + return http.StatusInternalServerError, err + } + + // Skip events we don't need to post + if eventMask&wh.EventMask() == 0 { + return http.StatusOK, nil + } + + // Post the event to the subscribed channel + _, statusCode, err := wh.PostToChannel(p, channel.Id, botUser.Id) + if err != nil { + return statusCode, err + } + + return http.StatusOK, nil +} diff --git a/server/webhook_http_test.go b/server/webhook_http_test.go new file mode 100644 index 000000000..5cf765cb6 --- /dev/null +++ b/server/webhook_http_test.go @@ -0,0 +1,250 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/plugin" + "github.com/mattermost/mattermost-server/plugin/plugintest" + "github.com/mattermost/mattermost-server/plugin/plugintest/mock" +) + +func testWebhookRequest(filename string) *http.Request { + if f, err := os.Open(filepath.Join("testdata", filename)); err != nil { + panic(err) + } else { + return httptest.NewRequest("POST", + "/webhook?team=theteam&channel=thechannel&secret=thesecret&updated_all=1", + f) + } +} + +type testWebhookWrapper struct { + Webhook + postedToChannel *model.Post + postedNotifications []*model.Post +} + +func (wh testWebhookWrapper) EventMask() uint64 { + return wh.Webhook.EventMask() +} +func (wh *testWebhookWrapper) PostToChannel(p *Plugin, channelId, fromUserId string) (*model.Post, int, error) { + post, status, err := wh.Webhook.PostToChannel(p, channelId, fromUserId) + if post != nil { + wh.postedToChannel = post + } + return post, status, err +} +func (wh *testWebhookWrapper) PostNotifications(p *Plugin, ji Instance) ([]*model.Post, int, error) { + posts, status, err := wh.Webhook.PostNotifications(p, ji) + if len(posts) != 0 { + wh.postedNotifications = append(wh.postedNotifications, posts...) + } + return posts, status, err +} + +func TestWebhookHTTP(t *testing.T) { + validConfiguration := TestConfiguration{ + Secret: "thesecret", + UserName: "theuser", + } + + for name, tc := range map[string]struct { + Request *http.Request + ExpectedHeadline string + ExpectedSlackAttachment bool + ExpectedText string + ExpectedFields []*model.SlackAttachmentField + }{ + "issue created": { + Request: testWebhookRequest("webhook-issue-created.json"), + ExpectedSlackAttachment: true, + ExpectedHeadline: "Test User created story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + ExpectedText: "story [Unit test summary](https://some-instance-test.atlassian.net/browse/TES-41)\n\nUnit test description, not that long\n", + ExpectedFields: []*model.SlackAttachmentField{ + &model.SlackAttachmentField{ + Title: "Priority", + Value: "High", + Short: true, + }, + }, + }, + "issue edited": { + Request: testWebhookRequest("webhook-issue-updated-edited.json"), + ExpectedSlackAttachment: true, + ExpectedHeadline: "Test User edited the description of story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + ExpectedText: "story [Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41)\n\nUnit test description, not that long, a little longer now\n", + }, + "issue renamed": { + Request: testWebhookRequest("webhook-issue-updated-renamed.json"), + ExpectedSlackAttachment: true, + ExpectedHeadline: "Test User renamed story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + ExpectedText: "story [Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "comment created": { + Request: testWebhookRequest("webhook-comment-created.json"), + ExpectedSlackAttachment: true, + ExpectedHeadline: "Test User commented on story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + ExpectedText: "Added a comment", + }, + "comment updated": { + Request: testWebhookRequest("webhook-comment-updated.json"), + ExpectedSlackAttachment: true, + ExpectedHeadline: "Test User edited comment in story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + ExpectedText: "Added a comment, then edited it", + }, + "comment deleted": { + Request: testWebhookRequest("webhook-comment-deleted.json"), + ExpectedHeadline: "Test User deleted comment in story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue assigned nobody": { + Request: testWebhookRequest("webhook-issue-updated-assigned-nobody.json"), + ExpectedHeadline: "Test User assigned _nobody_ to story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue assigned": { + Request: testWebhookRequest("webhook-issue-updated-assigned.json"), + ExpectedHeadline: "Test User assigned Test User to story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue attachments": { + Request: testWebhookRequest("webhook-issue-updated-attachments.json"), + ExpectedHeadline: "Test User attached [test.gif] to, removed attachments [test.json] from story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue labels": { + Request: testWebhookRequest("webhook-issue-updated-labels.json"), + ExpectedHeadline: "Test User added labels [sad] to, removed labels [bad] from story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue lowered priority": { + Request: testWebhookRequest("webhook-issue-updated-lowered-priority.json"), + ExpectedHeadline: `Test User updated priority from "Low" to "High" on story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)`, + }, + "issue raised priority": { + Request: testWebhookRequest("webhook-issue-updated-raised-priority.json"), + ExpectedHeadline: `Test User updated priority from "High" to "Low" on story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)`, + }, + "issue rank": { + Request: testWebhookRequest("webhook-issue-updated-rank.json"), + ExpectedHeadline: "Test User ranked higher story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue reopened": { + Request: testWebhookRequest("webhook-issue-updated-reopened.json"), + ExpectedHeadline: "Test User reopened story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue resolved": { + Request: testWebhookRequest("webhook-issue-updated-resolved.json"), + ExpectedHeadline: "Test User resolved story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + "issue sprint": { + Request: testWebhookRequest("webhook-issue-updated-sprint.json"), + ExpectedHeadline: "Test User moved story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41) to Sprint 2", + }, + "issue started working": { + Request: testWebhookRequest("webhook-issue-updated-started-working.json"), + ExpectedHeadline: "Test User updated status from \"In Progress\" to \"To Do\" on story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + + api.On("LogDebug", + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string")).Return(nil) + api.On("LogError", + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string")).Return(nil) + api.On("LogError", + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string"), + mock.AnythingOfTypeArgument("string")).Return(nil) + + api.On("GetUserByUsername", "theuser").Return(&model.User{ + Id: "theuserid", + }, (*model.AppError)(nil)) + api.On("GetChannelByNameForTeamName", "theteam", "thechannel", + false).Run(func(args mock.Arguments) { + api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, (*model.AppError)(nil)) + }).Return(&model.Channel{ + Id: "thechannelid", + TeamId: "theteamid", + }, (*model.AppError)(nil)) + + p := Plugin{} + p.updateConfig(func(conf *config) { + conf.Secret = validConfiguration.Secret + conf.UserName = validConfiguration.UserName + }) + p.SetAPI(api) + p.currentInstanceStore = mockCurrentInstanceStore{&p} + p.userStore = mockUserStore{} + + w := httptest.NewRecorder() + recorder := &testWebhookWrapper{} + prev := webhookWrapperFunc + defer func() { webhookWrapperFunc = prev }() + webhookWrapperFunc = func(wh Webhook) Webhook { + recorder.Webhook = wh + return recorder + } + p.ServeHTTP(&plugin.Context{}, w, tc.Request) + // assert.Equal(t, 0, w.Result().StatusCode) + require.NotNil(t, recorder.postedToChannel) + post := recorder.postedToChannel + + if !tc.ExpectedSlackAttachment { + assert.Equal(t, tc.ExpectedHeadline, post.Message) + return + } + + require.NotNil(t, post.Props) + require.NotNil(t, post.Props["attachments"]) + attachments := post.Props["attachments"].([]*model.SlackAttachment) + require.Equal(t, 1, len(attachments)) + + sa := attachments[0] + assert.Equal(t, tc.ExpectedHeadline, sa.Pretext) + assert.Equal(t, tc.ExpectedText, sa.Text) + require.Equal(t, len(tc.ExpectedFields), len(sa.Fields)) + for i := range tc.ExpectedFields { + assert.Equal(t, tc.ExpectedFields[i].Title, sa.Fields[i].Title) + assert.Equal(t, tc.ExpectedFields[i].Value, sa.Fields[i].Value) + assert.Equal(t, tc.ExpectedFields[i].Short, sa.Fields[i].Short) + } + }) + } +} diff --git a/server/webhook_jira.go b/server/webhook_jira.go new file mode 100644 index 000000000..5eff8a8fa --- /dev/null +++ b/server/webhook_jira.go @@ -0,0 +1,122 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "fmt" + "strings" + + "github.com/andygrunwald/go-jira" +) + +type JiraWebhook struct { + WebhookEvent string `json:"webhookEvent,omitempty"` + Issue jira.Issue `json:"issue,omitempty"` + User jira.User `json:"user,omitempty"` + Comment jira.Comment `json:"comment,omitempty"` + // TODO figure out why jira.Changelog didn't work + ChangeLog struct { + Items []struct { + From string + FromString string + To string + ToString string + Field string + } + } `json:"changelog,omitempty"` + IssueEventTypeName string `json:"issue_event_type_name"` +} + +func (jwh *JiraWebhook) mdJiraLink(title, suffix string) string { + pos := strings.LastIndex(jwh.Issue.Self, "/rest/api") + if pos < 0 { + return "" + } + return fmt.Sprintf("[%s](%s%s)", title, jwh.Issue.Self[:pos], suffix) +} + +func (jwh *JiraWebhook) mdIssueDescription() string { + return truncate(jwh.Issue.Fields.Description, 3000) +} + +func (jwh *JiraWebhook) mdIssueSummary() string { + return truncate(jwh.Issue.Fields.Summary, 80) +} + +func (w *JiraWebhook) mdIssueAssignee() string { + if w.Issue.Fields.Assignee == nil { + return "_nobody_" + } + return mdUser(w.Issue.Fields.Assignee) +} + +func (jwh *JiraWebhook) mdSummaryLink() string { + return jwh.mdIssueType() + " " + jwh.mdJiraLink(jwh.mdIssueSummary(), "/browse/"+jwh.Issue.Key) +} + +func (jwh *JiraWebhook) mdKeyLink() string { + return jwh.mdIssueType() + " " + jwh.mdJiraLink(jwh.Issue.Key, "/browse/"+jwh.Issue.Key) +} + +func (jwh *JiraWebhook) mdUser() string { + return mdUser(&jwh.User) +} + +func (jwh *JiraWebhook) mdIssueType() string { + return strings.ToLower(jwh.Issue.Fields.Type.Name) +} + +func mdAddRemove(from, to, add, remove string) string { + added := mdDiff(from, to) + removed := mdDiff(to, from) + s := "" + if added != "" { + s += fmt.Sprintf("%v [%v] to", add, added) + } + if removed != "" { + if added != "" { + s += ", " + } + s += fmt.Sprintf("%v [%v] from", remove, removed) + } + return s +} + +func mdDiff(from, to string) string { + fromStrings := strings.Split(from, " ") + toStrings := strings.Split(to, " ") + fromMap := map[string]bool{} + for _, s := range fromStrings { + fromMap[s] = true + } + toMap := map[string]bool{} + for _, s := range toStrings { + toMap[s] = true + } + added := []string{} + for s := range toMap { + if !fromMap[s] { + added = append(added, s) + } + } + + return strings.Join(added, ",") +} + +func mdUser(user *jira.User) string { + if user == nil { + return "" + } + return user.DisplayName +} + +func truncate(s string, max int) string { + if len(s) <= max || max < 0 { + return s + } + if max > 3 { + return s[:max-3] + "..." + } + return s[:max] +} diff --git a/server/webhook_markdown.go b/server/webhook_markdown.go deleted file mode 100644 index 15139470c..000000000 --- a/server/webhook_markdown.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License for license information. - -package main - -import ( - "fmt" - "io" - "strings" - - "github.com/andygrunwald/go-jira" - - "github.com/mattermost/mattermost-server/model" -) - -const ( - mdRootStyle = "## " - mdUpdateStyle = "###### " -) - -func AsMarkdown(in io.Reader) (func(post *model.Post), error) { - parsed, err := parse(in, func(w *JIRAWebhook) string { - return w.mdIssueLongLink() - }) - if err != nil { - return nil, err - } - - s := newMarkdownMessage(parsed) - - // Return a function that sets the Message on a post - return func(post *model.Post) { - post.Message = s - }, nil -} - -func newMarkdownMessage(parsed *parsedJIRAWebhook) string { - if parsed.headline == "" { - return "" - } - s := parsed.style + parsed.headline + "\n" - if parsed.details != "" { - s += parsed.details + "\n" - } - if parsed.text != "" { - s += parsed.text + "\n" - } - return s -} - -func (w *JIRAWebhook) mdIssueCreatedDetails() string { - attrs := []string{} - for _, a := range []string{ - w.mdIssuePriority(), - w.mdIssueAssignedTo(), - w.mdIssueReportedBy(), - w.mdIssueLabels(), - } { - if a != "" { - attrs = append(attrs, a) - } - } - s := strings.Join(attrs, ", ") - return s -} - -func (w *JIRAWebhook) mdIssueSummary() string { - return truncate(w.Issue.Fields.Summary, 80) -} - -func (w *JIRAWebhook) mdIssueDescription() string { - return truncate(w.Issue.Fields.Description, 3000) -} - -func (w *JIRAWebhook) mdIssueAssignee() string { - if w.Issue.Fields.Assignee == nil { - return "_nobody_" - } - return mdUser(w.Issue.Fields.Assignee) -} - -func (w *JIRAWebhook) mdIssueAssignedTo() string { - if w.Issue.Fields.Assignee == nil { - return "" - } - return "Assigned to: " + mdBOLD(w.mdIssueAssignee()) -} - -func (w *JIRAWebhook) mdIssueReportedBy() string { - if w.Issue.Fields.Reporter == nil { - return "" - } - return "Reported by: " + mdBOLD(mdUser(w.Issue.Fields.Reporter)) -} - -func (w *JIRAWebhook) mdIssueLabels() string { - if len(w.Issue.Fields.Labels) == 0 { - return "" - } - return "Labels: " + strings.Join(w.Issue.Fields.Labels, ",") -} - -func (w *JIRAWebhook) mdIssuePriority() string { - return "Priority: " + mdBOLD(w.Issue.Fields.Priority.Name) -} - -func (w *JIRAWebhook) mdIssueType() string { - return strings.ToLower(w.Issue.Fields.IssueType.Name) -} - -func (w *JIRAWebhook) mdIssueLongLink() string { - return fmt.Sprintf("[%v: %v](%v/browse/%v)", w.Issue.Key, w.mdIssueSummary(), w.jiraURL(), w.Issue.Key) -} - -func (w *JIRAWebhook) mdIssueLink() string { - return fmt.Sprintf("[%v](%v/browse/%v)", w.Issue.Key, w.jiraURL(), w.Issue.Key) -} - -func (w *JIRAWebhook) mdIssueHashtags() string { - s := "(" - if w.WebhookEvent == "jira:issue_created" { - s += "#jira-new " - } - s += "#" + w.Issue.Key - s += ")" - return s -} - -func mdAddRemove(from, to, add, remove string) string { - added := mdDiff(from, to) - removed := mdDiff(to, from) - s := "" - if added != "" { - s += fmt.Sprintf("%v [%v] to", add, added) - } - if removed != "" { - if added != "" { - s += ", " - } - s += fmt.Sprintf("%v [%v] from", remove, removed) - } - return s -} - -func mdDiff(from, to string) string { - fromStrings := strings.Split(from, " ") - toStrings := strings.Split(to, " ") - fromMap := map[string]bool{} - for _, s := range fromStrings { - fromMap[s] = true - } - toMap := map[string]bool{} - for _, s := range toStrings { - toMap[s] = true - } - added := []string{} - for s := range toMap { - if !fromMap[s] { - added = append(added, s) - } - } - - return strings.Join(added, ",") -} - -func mdUser(user *jira.User) string { - if user == nil { - return "" - } - return user.DisplayName -} - -func mdBOLD(s string) string { - return "**" + s + "**" -} - -func truncate(s string, max int) string { - if len(s) <= max || max < 0 { - return s - } - if max > 3 { - return s[:max-3] + "..." - } - return s[:max] -} diff --git a/server/webhook_markdown_test.go b/server/webhook_markdown_test.go deleted file mode 100644 index f41a3b1fc..000000000 --- a/server/webhook_markdown_test.go +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License for license information. - -package main - -import ( - "os" - "testing" - - "github.com/andygrunwald/go-jira" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParse(t *testing.T) { - for _, tc := range []struct { - file string - expectedStyle string - expectedHeadline string - expectedDetails string - expectedText string - }{{ - file: "testdata/webhook-comment-created.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User commented on story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - expectedText: "Added a comment", - }, { - file: "testdata/webhook-comment-deleted.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User removed a comment from story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-comment-updated.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User edited a comment in story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - expectedText: "Added a comment, then edited it", - }, { - file: "testdata/webhook-issue-created.json", - expectedStyle: mdRootStyle, - expectedHeadline: "Test User created story [TES-41: Unit test summary](https://some-instance-test.atlassian.net/browse/TES-41) (#jira-new #TES-41)", - expectedDetails: "Priority: **High**, Reported by: **Test User**, Labels: test-label", - expectedText: "Unit test description, not that long", - }, { - file: "testdata/webhook-issue-updated-assigned-nobody.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User assigned story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to _nobody_ (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-assigned.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User assigned story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to Test User (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-attachments.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User attached [test.gif] to, removed attachments [test.json] from story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-edited.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User edited description of story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - expectedText: "Unit test description, not that long, a little longer now", - }, { - file: "testdata/webhook-issue-updated-labels.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User added labels [sad] to, removed labels [bad] from story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-lowered-priority.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User lowered priority of story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to Low (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-raised-priority.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User raised priority of story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to High (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-rank.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User ranked higher story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-renamed.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User renamed story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to Unit test summary 1 (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-reopened.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User reopened story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-resolved.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User resolved story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-sprint.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User moved story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) to Sprint 2 (#TES-41)", - }, { - file: "testdata/webhook-issue-updated-started-working.json", - expectedStyle: mdUpdateStyle, - expectedHeadline: "Test User started working on story [TES-41: Unit test summary 1](https://some-instance-test.atlassian.net/browse/TES-41) (#TES-41)", - }, - } { - t.Run(tc.file, func(t *testing.T) { - f, err := os.Open(tc.file) - require.NoError(t, err) - defer f.Close() - parsed, err := parse(f, func(w *JIRAWebhook) string { - return w.mdIssueLongLink() - }) - require.NoError(t, err) - assert.Equal(t, tc.expectedStyle, parsed.style) - assert.Equal(t, tc.expectedHeadline, parsed.headline) - assert.Equal(t, tc.expectedDetails, parsed.details) - assert.Equal(t, tc.expectedText, parsed.text) - }) - } -} - -func TestMarkdown(t *testing.T) { - f, err := os.Open("testdata/webhook-issue-created.json") - require.NoError(t, err) - defer f.Close() - parsed, err := parse(f, func(w *JIRAWebhook) string { - return w.mdIssueLongLink() - }) - require.NoError(t, err) - m := newMarkdownMessage(parsed) - - assert.Equal(t, "## Test User created story [TES-41: Unit test summary](https://some-instance-test.atlassian.net/browse/TES-41) (#jira-new #TES-41)\nPriority: **High**, Reported by: **Test User**, Labels: test-label\nUnit test description, not that long\n", m) -} - -func TestWebhookVariousErrorsForCoverage(t *testing.T) { - assert.Equal(t, "", mdUser(nil)) - - parsed := &parsedJIRAWebhook{ - JIRAWebhook: &JIRAWebhook{}, - } - assert.Equal(t, "", parsed.mdIssueReportedBy()) - assert.Equal(t, "", parsed.mdIssueLabels()) - assert.Equal(t, "", parsed.jiraURL()) - parsed.fromChangeLog("link") - assert.Equal(t, "", parsed.headline) - assert.Equal(t, "", parsed.text) - - parsed.WebhookEvent = "something-else" - assert.Equal(t, "", newMarkdownMessage(parsed)) - - parsed.WebhookEvent = "jira:issue_updated" - parsed.IssueEventTypeName = "something-else" - assert.Equal(t, "", newMarkdownMessage(parsed)) - - parsed.Issue.Fields.Assignee = &jira.User{ - DisplayName: "test", - } - assert.Equal(t, "Assigned to: **test**", parsed.mdIssueAssignedTo()) -} - -func TestTruncate(t *testing.T) { - assert.Equal(t, "12345", truncate("12345", 5)) - assert.Equal(t, "12345", truncate("12345", 6)) - assert.Equal(t, "1...", truncate("12345", 4)) - assert.Equal(t, "12", truncate("12345", 2)) - assert.Equal(t, "1", truncate("12345", 1)) - assert.Equal(t, "", truncate("12345", 0)) - assert.Equal(t, "12345", truncate("12345", -1)) -} - -func TestWebhookJiraURL(t *testing.T) { - var w JIRAWebhook - w.Issue.Self = "http://localhost:8080/rest/api/2/issue/10006" - assert.Equal(t, "http://localhost:8080", w.jiraURL()) - - w.Issue.Self = "http://localhost:8080/foo/bar/rest/api/2/issue/10006" - assert.Equal(t, "http://localhost:8080/foo/bar", w.jiraURL()) -} diff --git a/server/webhook_parser.go b/server/webhook_parser.go new file mode 100644 index 000000000..c9d8c9e16 --- /dev/null +++ b/server/webhook_parser.go @@ -0,0 +1,251 @@ +// See License for license information. +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. + +package main + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/model" +) + +var webhookWrapperFunc func(wh Webhook) Webhook + +func ParseWebhook(in io.Reader) (Webhook, *JiraWebhook, error) { + jwh := &JiraWebhook{} + err := json.NewDecoder(in).Decode(&jwh) + if err != nil { + return nil, nil, err + } + if jwh.WebhookEvent == "" { + return nil, jwh, errors.New("No webhook event") + } + if jwh.Issue.Fields == nil { + return nil, jwh, errors.New("Invalid webhook event") + } + + var wh Webhook + switch jwh.WebhookEvent { + case "jira:issue_created": + wh = parseWebhookCreated(jwh) + case "jira:issue_deleted": + wh = parseWebhookDeleted(jwh) + case "jira:issue_updated": + switch jwh.IssueEventTypeName { + case "issue_assigned": + wh = parseWebhookAssigned(jwh) + case "issue_updated", "issue_generic": + wh = parseWebhookChangeLog(jwh) + } + case "comment_created": + wh = parseWebhookCommentCreated(jwh) + case "comment_updated": + wh = parseWebhookCommentUpdated(jwh) + case "comment_deleted": + wh = parseWebhookCommentDeleted(jwh) + } + if wh == nil { + return nil, jwh, errors.Errorf("Unsupported webhook data: %v", jwh.WebhookEvent) + } + + // For HTTP testing, so we can capture the output of the interface + if webhookWrapperFunc != nil { + wh = webhookWrapperFunc(wh) + } + + return wh, jwh, nil +} + +func parseWebhookChangeLog(jwh *JiraWebhook) Webhook { + for _, item := range jwh.ChangeLog.Items { + field := item.Field + to := item.ToString + from := item.FromString + switch { + case field == "resolution" && to == "" && from != "": + return parseWebhookReopened(jwh) + case field == "resolution" && to != "" && from == "": + return parseWebhookResolved(jwh) + case field == "status": + return parseWebhookUpdatedField(jwh, eventUpdatedStatus, field, to, from) + case field == "priority": + return parseWebhookUpdatedField(jwh, eventUpdatedPriority, field, to, from) + case field == "summary": + return parseWebhookUpdatedSummary(jwh) + case field == "description": + return parseWebhookUpdatedDescription(jwh) + case field == "Sprint" && len(to) > 0: + return parseWebhookUpdatedSprint(jwh, to) + case field == "Rank" && len(to) > 0: + return parseWebhookUpdatedRank(jwh, strings.ToLower(to)) + case field == "Attachment": + return parseWebhookUpdatedAttachments(jwh, from, to) + case field == "labels": + return parseWebhookUpdatedLabels(jwh, from, to) + } + } + return nil +} + +func parseWebhookCreated(jwh *JiraWebhook) Webhook { + wh := newWebhook(jwh, eventCreated, "created") + + wh.text = jwh.mdSummaryLink() + desc := jwh.mdIssueDescription() + if desc != "" { + wh.text += "\n\n" + desc + "\n" + } + + if jwh.Issue.Fields == nil { + return wh + } + var fields []*model.SlackAttachmentField + if jwh.Issue.Fields.Assignee != nil { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Assignee", + Value: jwh.Issue.Fields.Assignee.DisplayName, + Short: true, + }) + } + if jwh.Issue.Fields.Priority != nil { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Priority", + Value: jwh.Issue.Fields.Priority.Name, + Short: true, + }) + } + if len(fields) > 0 { + wh.fields = fields + } + + return wh +} + +func parseWebhookDeleted(jwh *JiraWebhook) Webhook { + wh := newWebhook(jwh, eventDeleted, "deleted") + if jwh.Issue.Fields != nil && jwh.Issue.Fields.Resolution == nil { + wh.eventMask = wh.eventMask | eventDeletedUnresolved + } + return wh +} + +func parseWebhookCommentCreated(jwh *JiraWebhook) Webhook { + wh := &webhook{ + JiraWebhook: jwh, + eventMask: eventCreatedComment, + headline: fmt.Sprintf("%s commented on %s", mdUser(&jwh.Comment.UpdateAuthor), jwh.mdKeyLink()), + text: truncate(jwh.Comment.Body, 3000), + } + + message := fmt.Sprintf("%s mentioned you on %s:\n>%s", + jwh.mdUser(), jwh.mdKeyLink(), jwh.Comment.Body) + for _, u := range parseJIRAUsernamesFromText(wh.Comment.Body) { + // don't mention the author of the comment + if u == jwh.User.Name { + continue + } + // don't mention the Issue assignee, will gets a special notice + if jwh.Issue.Fields.Assignee != nil && u == jwh.Issue.Fields.Assignee.Name { + continue + } + + wh.notifications = append(wh.notifications, webhookNotification{ + jiraUsername: u, + message: message, + postType: PostTypeMention, + }) + } + + if jwh.Issue.Fields.Assignee == nil || jwh.Issue.Fields.Assignee.Name == jwh.User.Name { + return wh + } + + wh.notifications = append(wh.notifications, webhookNotification{ + jiraUsername: jwh.Issue.Fields.Assignee.Name, + message: fmt.Sprintf("%s commented on %s:\n>%s", jwh.mdUser(), jwh.mdKeyLink(), jwh.Comment.Body), + postType: PostTypeComment, + }) + return wh +} + +func parseWebhookCommentDeleted(jwh *JiraWebhook) Webhook { + return &webhook{ + JiraWebhook: jwh, + eventMask: eventDeletedComment, + headline: fmt.Sprintf("%s deleted comment in %s", mdUser(&jwh.Comment.UpdateAuthor), jwh.mdKeyLink()), + } +} + +func parseWebhookCommentUpdated(jwh *JiraWebhook) Webhook { + return &webhook{ + JiraWebhook: jwh, + eventMask: eventUpdatedComment, + headline: fmt.Sprintf("%s edited comment in %s", mdUser(&jwh.Comment.UpdateAuthor), jwh.mdKeyLink()), + text: truncate(jwh.Comment.Body, 3000), + } +} + +func parseWebhookAssigned(jwh *JiraWebhook) Webhook { + wh := newWebhook(jwh, eventUpdatedAssignee, "assigned %s to", jwh.mdIssueAssignee()) + if jwh.Issue.Fields.Assignee == nil { + return wh + } + wh.notifications = append(wh.notifications, webhookNotification{ + jiraUsername: jwh.Issue.Fields.Assignee.Name, + message: fmt.Sprintf("%s assigned you to %s", jwh.mdUser(), jwh.mdKeyLink()), + }) + return wh +} + +func parseWebhookReopened(jwh *JiraWebhook) Webhook { + return newWebhook(jwh, eventUpdatedReopened, "reopened") +} + +func parseWebhookResolved(jwh *JiraWebhook) Webhook { + return newWebhook(jwh, eventUpdatedResolved, "resolved") +} + +func parseWebhookUpdatedField(jwh *JiraWebhook, eventMask uint64, field, from, to string) Webhook { + return newWebhook(jwh, eventMask, "updated %s from %q to %q on", field, from, to) +} + +func parseWebhookUpdatedSummary(jwh *JiraWebhook) Webhook { + wh := newWebhook(jwh, eventUpdatedSummary, "renamed") + wh.text = jwh.mdSummaryLink() + return wh +} + +func parseWebhookUpdatedDescription(jwh *JiraWebhook) Webhook { + wh := newWebhook(jwh, eventUpdatedDescription, "edited the description of") + wh.text = jwh.mdSummaryLink() + desc := jwh.mdIssueDescription() + if desc != "" { + wh.text += "\n\n" + desc + "\n" + } + return wh +} + +func parseWebhookUpdatedSprint(jwh *JiraWebhook, to string) Webhook { + return &webhook{ + JiraWebhook: jwh, + eventMask: eventUpdatedSprint, + headline: fmt.Sprintf("%s moved %s to %s", jwh.mdUser(), jwh.mdKeyLink(), to), + } +} + +func parseWebhookUpdatedRank(jwh *JiraWebhook, to string) Webhook { + return newWebhook(jwh, eventUpdatedRank, to) +} + +func parseWebhookUpdatedAttachments(jwh *JiraWebhook, from, to string) Webhook { + return newWebhook(jwh, eventUpdatedAttachment, mdAddRemove(from, to, "attached", "removed attachments")) +} + +func parseWebhookUpdatedLabels(jwh *JiraWebhook, from, to string) Webhook { + return newWebhook(jwh, eventUpdatedLabels, mdAddRemove(from, to, "added labels", "removed labels")) +} diff --git a/server/webhook_parser_misc_test.go b/server/webhook_parser_misc_test.go new file mode 100644 index 000000000..a020df763 --- /dev/null +++ b/server/webhook_parser_misc_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +package main + +import ( + "os" + "testing" + + "github.com/andygrunwald/go-jira" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarkdown(t *testing.T) { + f, err := os.Open("testdata/webhook-issue-created.json") + require.NoError(t, err) + defer f.Close() + wh, _, err := ParseWebhook(f) + require.NoError(t, err) + w := wh.(*webhook) + require.NotNil(t, w) + require.Equal(t, + "Test User created story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41)", + w.headline) +} + +func TestWebhookVariousErrors(t *testing.T) { + assert.Equal(t, "", mdUser(nil)) + + wh := &webhook{ + JiraWebhook: &JiraWebhook{ + Issue: jira.Issue{ + Fields: &jira.IssueFields{}, + }, + }, + } + + assert.Equal(t, "", wh.mdJiraLink("test", "/test")) + assert.Equal(t, "", wh.mdIssueDescription()) + assert.Equal(t, "", wh.mdIssueSummary()) + assert.Equal(t, "_nobody_", wh.mdIssueAssignee()) + assert.Equal(t, "", wh.mdIssueType()) + assert.Equal(t, " ", wh.mdSummaryLink()) + assert.Equal(t, " ", wh.mdKeyLink()) + assert.Equal(t, "", wh.mdUser()) +} + +func TestTruncate(t *testing.T) { + assert.Equal(t, "12345", truncate("12345", 5)) + assert.Equal(t, "12345", truncate("12345", 6)) + assert.Equal(t, "1...", truncate("12345", 4)) + assert.Equal(t, "12", truncate("12345", 2)) + assert.Equal(t, "1", truncate("12345", 1)) + assert.Equal(t, "", truncate("12345", 0)) + assert.Equal(t, "12345", truncate("12345", -1)) +} + +func TestJiraLink(t *testing.T) { + var jwh JiraWebhook + jwh.Issue.Self = "http://localhost:8080/rest/api/2/issue/10006" + assert.Equal(t, "[1](http://localhost:8080/XXX)", jwh.mdJiraLink("1", "/XXX")) + + jwh.Issue.Self = "http://localhost:8080/foo/bar/rest/api/2/issue/10006" + assert.Equal(t, "[1](http://localhost:8080/foo/bar/QWERTY)", jwh.mdJiraLink("1", "/QWERTY")) +} diff --git a/server/webhook_slack_attachment.go b/server/webhook_slack_attachment.go deleted file mode 100644 index 14fb3fe31..000000000 --- a/server/webhook_slack_attachment.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License for license information. - -package main - -import ( - "github.com/mattermost/mattermost-server/model" -) - -func AsSlackAttachment(parsed *parsedJIRAWebhook) func(post *model.Post) { - a := newSlackAttachment(parsed) - - // Return a function that adds to a post as a SlackAttachment - return func(post *model.Post) { - model.ParseSlackAttachment(post, []*model.SlackAttachment{a}) - } -} - -func newSlackAttachment(parsed *parsedJIRAWebhook) *model.SlackAttachment { - if parsed.headline == "" { - return nil - } - - a := &model.SlackAttachment{ - Color: "#95b7d0", - Fallback: parsed.headline, - Pretext: parsed.headline, - Text: parsed.text, - } - - text := parsed.mdIssueLongLink() + "\n" - if parsed.text != "" { - text += "\n" - text += parsed.text + "\n" - } - - var fields []*model.SlackAttachmentField - if parsed.WebhookEvent == "jira:issue_created" { - - if parsed.Issue.Fields.Assignee != nil { - fields = append(fields, &model.SlackAttachmentField{ - Title: "Assignee", - Value: parsed.Issue.Fields.Assignee.DisplayName, - Short: true, - }) - } - if parsed.Issue.Fields.Priority != nil { - fields = append(fields, &model.SlackAttachmentField{ - Title: "Priority", - Value: parsed.Issue.Fields.Priority.Name, - Short: true, - }) - } - } - - a.Text = text - a.Fields = fields - return a -} diff --git a/server/webhook_slack_attachment_test.go b/server/webhook_slack_attachment_test.go deleted file mode 100644 index 2b5c94af9..000000000 --- a/server/webhook_slack_attachment_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. -// See License for license information. - -package main - -import ( - "os" - "testing" - - "github.com/mattermost/mattermost-server/model" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSlackAttachment(t *testing.T) { - f, err := os.Open("testdata/webhook-issue-created.json") - require.NoError(t, err) - defer f.Close() - parsed, err := parse(f, nil) - require.NoError(t, err) - a := newSlackAttachment(parsed) - - assert.Equal(t, "Test User created story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41) (#jira-new #TES-41)", a.Fallback) - assert.Equal(t, "#95b7d0", a.Color) - assert.Equal(t, "Test User created story [TES-41](https://some-instance-test.atlassian.net/browse/TES-41) (#jira-new #TES-41)", a.Pretext) - assert.Equal(t, "[TES-41: Unit test summary](https://some-instance-test.atlassian.net/browse/TES-41)\n\nUnit test description, not that long\n", a.Text) - assert.Equal(t, 1, len(a.Fields)) - assert.Equal(t, &model.SlackAttachmentField{Title: "Priority", Value: "High", Short: true}, a.Fields[0]) -} - -func TestSlackAttachmentForCoverage(t *testing.T) { - parsed := &parsedJIRAWebhook{ - JIRAWebhook: &JIRAWebhook{}, - } - parsed.WebhookEvent = "something-else" - assert.Nil(t, newSlackAttachment(parsed)) - - parsed.WebhookEvent = "jira:issue_updated" - parsed.IssueEventTypeName = "something-else" - assert.Nil(t, newSlackAttachment(parsed)) -}