diff --git a/go.mod b/go.mod index e03d7b998..7151d3384 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/mattermost/mattermost-plugin-jira go 1.13 require ( - github.com/andygrunwald/go-jira v1.10.0 + github.com/andygrunwald/go-jira v1.15.1 github.com/blang/semver/v4 v4.0.0 github.com/dghubble/oauth1 v0.5.0 github.com/dgrijalva/jwt-go v3.2.0+incompatible diff --git a/go.sum b/go.sum index aa0672dca..5d7756c40 100644 --- a/go.sum +++ b/go.sum @@ -169,8 +169,8 @@ github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= -github.com/andygrunwald/go-jira v1.10.0 h1:+HPPK7++6/hW8ygtr2Yc0wd+Qu139NrWiTD/r1cYxO0= -github.com/andygrunwald/go-jira v1.10.0/go.mod h1:KEsrADP1cEXRxVWTaDtpLyyZN1LM9p6Jn8W5+sDzxhc= +github.com/andygrunwald/go-jira v1.15.1 h1:6J9aYKb9sW8bxv3pBLYBrs0wdsFrmGI5IeTgWSKWKc8= +github.com/andygrunwald/go-jira v1.15.1/go.mod h1:GIYN1sHOIsENWUZ7B4pDeT/nxEtrZpE8l0987O67ZR8= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= @@ -526,7 +526,6 @@ github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGE github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= -github.com/fatih/structs v1.0.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fcjr/aia-transport-go v1.2.2/go.mod h1:onSqSq3tGkM14WusDx7q9FTheS9R1KBtD+QBWI6zG/w= @@ -658,7 +657,10 @@ github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5 github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= +github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-migrate/migrate/v4 v4.14.1/go.mod h1:l7Ks0Au6fYHuUIxUhQ0rcVX1uLlJg54C/VvW7tvxSz0= github.com/golang-migrate/migrate/v4 v4.15.1/go.mod h1:/CrBenUbcDqsW29jGTR/XFqCfVi/Y6mHXlooCcSOJMQ= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -724,13 +726,14 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-github/v35 v35.2.0/go.mod h1:s0515YVTI+IMrDoy9Y4pHt9ShGpzHvHO8rZ7L7acgvs= -github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -1589,8 +1592,8 @@ github.com/tinylib/msgp v1.1.6 h1:i+SbKraHhnrf9M5MYmvQhFnbLhAXSDWF8WWsuyRdocw= github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/trivago/tgo v1.0.1 h1:bxatjJIXNIpV18bucU4Uk/LaoxvxuOlp/oowRHyncLQ= -github.com/trivago/tgo v1.0.1/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= +github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= +github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/tylerb/graceful v1.2.15/go.mod h1:LPYTbOYmUTdabwRt0TGhLllQ0MUNbs0Y5q1WXJOI9II= @@ -1743,7 +1746,6 @@ golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190506204251-e1dfcc566284/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -2083,6 +2085,7 @@ golang.org/x/sys v0.0.0-20220207234003-57398862261d h1:Bm7BNOQt2Qv7ZqysjeLjgCBan golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/server/client.go b/server/client.go index 2e83a8ad5..6576f8908 100644 --- a/server/client.go +++ b/server/client.go @@ -55,7 +55,7 @@ type UserService interface { // ProjectService is the interface for project-related APIs. type ProjectService interface { GetProject(key string) (*jira.Project, error) - ListProjects(query string, limit int) (jira.ProjectList, error) + GetAllProjectKeys() ([]string, error) } // SearchService is the interface for search-related APIs. @@ -78,6 +78,7 @@ type IssueService interface { GetTransitions(issueKey string) ([]jira.Transition, error) UpdateAssignee(issueKey string, user *jira.User) error UpdateComment(issueKey string, comment *jira.Comment) (*jira.Comment, error) + GetWatchers(instanceID, issueKey string, connection *Connection) (*jira.Watches, error) } // JiraClient is the common implementation of most Jira APIs, except those that are @@ -168,6 +169,20 @@ func (client JiraClient) RESTPostAttachment(issueID string, data []byte, name st return attachments[0], nil } +func (client JiraClient) GetAllProjectKeys() ([]string, error) { + projectList, resp, err := client.Jira.Project.GetList() + if err != nil { + return nil, userFriendlyJiraError(resp, err) + } + + keys := make([]string, len(*projectList)) + for index, project := range *projectList { + keys[index] = project.Key + } + + return keys, nil +} + // GetProject returns a Project by key. func (client JiraClient) GetProject(key string) (*jira.Project, error) { project, resp, err := client.Jira.Project.Get(key) @@ -186,6 +201,21 @@ func (client JiraClient) GetIssue(key string, options *jira.GetQueryOptions) (*j return issue, nil } +// GetWatchers returns an array of Jira users watching a given issue. +func (client JiraClient) GetWatchers(instanceID, issueKey string, connection *Connection) (*jira.Watches, error) { + var watchers jira.Watches + params := map[string]string{ + "accountId": connection.AccountID, + } + + endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s/watchers", instanceID, issueKey) + + if err := client.RESTGet(endpoint, params, &watchers); err != nil { + return nil, err + } + return &watchers, nil +} + // GetTransitions returns transitions for an issue with issueKey. func (client JiraClient) GetTransitions(issueKey string) ([]jira.Transition, error) { transitions, resp, err := client.Jira.Issue.GetTransitions(issueKey) @@ -391,7 +421,7 @@ func endpointURL(endpoint string) (string, error) { return "", err } if parsedURL.Scheme == "" { - // relative path + // Relative path endpoint = path.Join("/rest/api", endpoint) } return endpoint, nil diff --git a/server/client_cloud.go b/server/client_cloud.go index b919891f0..96588267a 100644 --- a/server/client_cloud.go +++ b/server/client_cloud.go @@ -5,7 +5,6 @@ package main import ( "encoding/json" - "strconv" jira "github.com/andygrunwald/go-jira" "github.com/pkg/errors" @@ -67,50 +66,3 @@ func (client jiraCloudClient) GetUserGroups(connection *Connection) ([]*jira.Use } return groups, nil } - -func (client jiraCloudClient) ListProjects(query string, limit int) (jira.ProjectList, error) { - type searchResult struct { - Values jira.ProjectList `json:"values"` - StartAt int `json:"startAt"` - MaxResults int `json:"maxResults"` - Total int `json:"total"` - IsLast bool `json:"isLast"` - } - - remaining := 50 - fetchAll := false - if limit > 0 { - remaining = limit - } - if limit < 0 { - fetchAll = true - } - - var out jira.ProjectList - for { - opts := map[string]string{ - "startAt": strconv.Itoa(len(out)), - "maxResults": strconv.Itoa(remaining), - "expand": "issueTypes", - } - var result searchResult - err := client.RESTGet("/3/project/search", opts, &result) - if err != nil { - return nil, err - } - if len(result.Values) > remaining { - result.Values = result.Values[:remaining] - } - out = append(out, result.Values...) - remaining -= len(result.Values) - - if !fetchAll && remaining == 0 { - // Got enough. - return out, nil - } - if len(result.Values) == 0 || result.IsLast { - // Ran out of results. - return out, nil - } - } -} diff --git a/server/client_server.go b/server/client_server.go index 187bc5712..5dda2fa8d 100644 --- a/server/client_server.go +++ b/server/client_server.go @@ -173,18 +173,3 @@ func (client jiraServerClient) GetUserGroups(connection *Connection) ([]*jira.Us } return result.Groups.Items, nil } - -func (client jiraServerClient) ListProjects(query string, limit int) (jira.ProjectList, error) { - plist, resp, err := client.Jira.Project.GetList() - if err != nil { - return nil, userFriendlyJiraError(resp, err) - } - if plist == nil { - return jira.ProjectList{}, nil - } - result := *plist - if limit > 0 && len(result) > limit { - result = result[:limit] - } - return result, nil -} diff --git a/server/command.go b/server/command.go index 7f5819758..d2e7c051f 100644 --- a/server/command.go +++ b/server/command.go @@ -69,8 +69,9 @@ const commonHelpText = "\n" + "* `/jira help` - Launch the Jira plugin command line help syntax\n" + "* `/jira info` - Display information about the current user and the Jira plug-in\n" + "* `/jira instance list` - List installed Jira instances\n" + - "* `/jira instance settings [setting] [value]` - Update your user settings\n" + + "* `/jira instance settings [setting] [role] [value]` - Update your user settings\n" + " * [setting] can be `notifications`\n" + + " * [role] can be `assignee` , `mention` , `reporter` or `watching`\n" + " * [value] can be `on` or `off`\n" + "" @@ -252,12 +253,29 @@ func createSettingsCommand(optInstance bool) *model.AutocompleteData { "list", "", "View your current settings") settings.AddCommand(list) - notifications := model.NewAutocompleteData( - "notifications", "[on|off]", "Update your user notifications settings") - notifications.AddStaticListArgument("value", true, []model.AutocompleteListItem{ + setting := []model.AutocompleteListItem{ {HelpText: "Turn notifications on", Item: "on"}, {HelpText: "Turn notifications off", Item: "off"}, - }) + } + + notifications := model.NewAutocompleteData( + "notifications", "[assignee|mention|reporter|watching]", "manage notifications") + + assigneeNotifications := model.NewAutocompleteData(assigneeRole, "", "manage assignee notifications") + assigneeNotifications.AddStaticListArgument("value", true, setting) + + mentionNotifications := model.NewAutocompleteData(mentionRole, "", "manage mention notifications") + mentionNotifications.AddStaticListArgument("value", true, setting) + + reporterNotifications := model.NewAutocompleteData(reporterRole, "", "manage reporter notifications") + reporterNotifications.AddStaticListArgument("value", true, setting) + + watchingNotifications := model.NewAutocompleteData(watchingRole, "", "manage watching notifications") + reporterNotifications.AddStaticListArgument("value", true, setting) + notifications.AddCommand(assigneeNotifications) + notifications.AddCommand(mentionNotifications) + notifications.AddCommand(reporterNotifications) + notifications.AddCommand(watchingNotifications) withFlagInstance(notifications, optInstance, routeAutocompleteInstalledInstanceWithAlias) settings.AddCommand(notifications) @@ -375,10 +393,12 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, commandArgs *model.CommandArg if err != nil { return p.responsef(commandArgs, err.Error()), nil } + args := strings.Fields(commandArgs.Command) if len(args) == 0 || args[0] != "/jira" { return p.help(commandArgs), nil } + return jiraCommandHandler.Handle(p, c, commandArgs, args[1:]...), nil } @@ -591,7 +611,10 @@ func executeSettings(p *Plugin, c *plugin.Context, header *model.CommandArgs, ar switch args[0] { case "list": - return p.responsef(header, "Current settings:\n%s", conn.Settings.String()) + if conn.Settings != nil { + return p.responsef(header, "Current settings:\n%s", conn.Settings.String()) + } + return p.responsef(header, "Please connect to Jira account using the command `/jira connect`") case "notifications": return p.settingsNotifications(header, instance.GetID(), user.MattermostUserID, conn, args) default: diff --git a/server/command_test.go b/server/command_test.go index 01a1c307d..c3f0cf215 100644 --- a/server/command_test.go +++ b/server/command_test.go @@ -65,12 +65,20 @@ func getMockUserStoreKV() mockUserStoreKV { connection := Connection{ User: jira.User{ - AccountID: "test", + AccountID: "test-AccountID", }, } withNotifications := connection // copy - withNotifications.Settings = &ConnectionSettings{Notifications: true} + withNotifications.Settings = &ConnectionSettings{ + Notifications: true, + RolesForDMNotification: map[string]bool{ + assigneeRole: true, + mentionRole: true, + reporterRole: true, + watchingRole: true, + }, + } return mockUserStoreKV{ users: map[types.ID]*User{ @@ -165,12 +173,12 @@ func TestPlugin_ExecuteCommand_Settings(t *testing.T) { "no params, with notifications": { commandArgs: &model.CommandArgs{Command: "/jira settings", UserId: mockUserIDWithNotifications}, numInstances: 1, - expectedMsg: "Current settings:\n\tNotifications: on", + expectedMsg: "Current settings:\n\t- Notifications for assignee: on \n\t- Notifications for mention: on \n\t- Notifications for reporter: on \n\t- Notifications for watching: on", }, "no params, without notifications": { commandArgs: &model.CommandArgs{Command: "/jira settings", UserId: mockUserIDWithoutNotifications}, numInstances: 1, - expectedMsg: "Current settings:\n\tNotifications: off", + expectedMsg: "Current settings:\n\t- Notifications for assignee: off \n\t- Notifications for mention: off \n\t- Notifications for reporter: off \n\t- Notifications for watching: off", }, "unknown setting": { commandArgs: &model.CommandArgs{Command: "/jira settings" + " test", UserId: mockUserIDWithoutNotifications}, @@ -180,22 +188,52 @@ func TestPlugin_ExecuteCommand_Settings(t *testing.T) { "set notifications without value": { commandArgs: &model.CommandArgs{Command: "/jira settings" + " notifications", UserId: mockUserIDWithoutNotifications}, numInstances: 1, - expectedMsg: "`/jira settings notifications [value]`\n* Invalid value. Accepted values are: `on` or `off`.", + expectedMsg: "`/jira settings notifications [assignee|mention|reporter|watching] [value]`\n* Invalid value. Accepted values are: `on` or `off`.", }, "set notification with unknown value": { commandArgs: &model.CommandArgs{Command: "/jira settings notifications test", UserId: mockUserIDWithoutNotifications}, numInstances: 1, - expectedMsg: "`/jira settings notifications [value]`\n* Invalid value. Accepted values are: `on` or `off`.", + expectedMsg: "`/jira settings notifications [assignee|mention|reporter|watching] [value]`\n* Invalid value. Accepted values are: `on` or `off`.", + }, + "enable assignee notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications assignee on", UserId: mockUserIDWithoutNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tAssignee notifications on.", + }, + "disable assignee notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications assignee off", UserId: mockUserIDWithNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tAssignee notifications off.", + }, + "enable reporter notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications reporter on", UserId: mockUserIDWithoutNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tReporter notifications on.", + }, + "disable reporter notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications reporter off", UserId: mockUserIDWithNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tReporter notifications off.", + }, + "enable mention notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications mention on", UserId: mockUserIDWithoutNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tMention notifications on.", + }, + "disable mention notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications mention off", UserId: mockUserIDWithNotifications}, + numInstances: 1, + expectedMsg: "Settings updated.\n\tMention notifications off.", }, - "enable notifications": { - commandArgs: &model.CommandArgs{Command: "/jira settings notifications on", UserId: mockUserIDWithoutNotifications}, + "enable watching notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications watching on", UserId: mockUserIDWithoutNotifications}, numInstances: 1, - expectedMsg: "Settings updated. Notifications on.", + expectedMsg: "Settings updated.\n\tWatching notifications on.", }, - "disable notifications": { - commandArgs: &model.CommandArgs{Command: "/jira settings notifications off", UserId: mockUserIDWithNotifications}, + "disable watching notifications": { + commandArgs: &model.CommandArgs{Command: "/jira settings notifications watching off", UserId: mockUserIDWithNotifications}, numInstances: 1, - expectedMsg: "Settings updated. Notifications off.", + expectedMsg: "Settings updated.\n\tWatching notifications off.", }, } for name, tt := range tests { @@ -225,7 +263,7 @@ func TestPlugin_ExecuteCommand_Settings(t *testing.T) { func TestPlugin_ExecuteCommand_Installation(t *testing.T) { api := &plugintest.API{} api.On("LogError", mock.AnythingOfTypeArgument("string")).Return(nil) - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() api.On("KVSet", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(nil) api.On("KVSetWithExpiry", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(nil) api.On("KVGet", keyInstances).Return(nil, nil) diff --git a/server/http.go b/server/http.go index a0189ebda..c59ace5d5 100644 --- a/server/http.go +++ b/server/http.go @@ -104,10 +104,6 @@ func (p *Plugin) serveHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req routeUserStart, routeAPISubscribeWebhook: - if path == routeIncomingWebhook && instanceURL == "" { - break - } - callbackInstanceID, err = p.ResolveWebhookInstanceURL(instanceURL) if err != nil { return respondErr(w, http.StatusInternalServerError, err) diff --git a/server/http_test.go b/server/http_test.go index 3a391f629..2a79d849a 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -316,9 +316,9 @@ func TestSubscribe(t *testing.T) { api := &plugintest.API{} p := Plugin{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.ChannelMember{}, (*model.AppError)(nil)) api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) @@ -427,9 +427,9 @@ func TestDeleteSubscription(t *testing.T) { api := &plugintest.API{} p := Plugin{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.ChannelMember{}, (*model.AppError)(nil)) api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) @@ -646,9 +646,9 @@ func TestEditSubscription(t *testing.T) { api := &plugintest.API{} p := Plugin{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.ChannelMember{}, (*model.AppError)(nil)) api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) @@ -807,9 +807,9 @@ func TestGetSubscriptionsForChannel(t *testing.T) { api := &plugintest.API{} p := Plugin{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("GetChannelMember", mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.ChannelMember{}, (*model.AppError)(nil)) diff --git a/server/issue.go b/server/issue.go index 54610be58..5a4901053 100644 --- a/server/issue.go +++ b/server/issue.go @@ -22,12 +22,17 @@ import ( ) const ( - labelsField = "labels" - statusField = "status" - reporterField = "reporter" - priorityField = "priority" - descriptionField = "description" - resolutionField = "resolution" + labelsField = "labels" + statusField = "status" + reporterField = "reporter" + priorityField = "priority" + descriptionField = "description" + resolutionField = "resolution" + createdCommentEvent = "event_created_comment" + notificationTypeReporter = "reporter" + notificationTypeWatching = "watching" + jiraUserName = "Name" + jiraUserAccountID = "AccountID" ) func makePost(userID, channelID, message string) *model.Post { @@ -502,13 +507,13 @@ func (p *Plugin) httpGetJiraProjectMetadata(w http.ResponseWriter, r *http.Reque instanceID := r.FormValue("instance_id") - plist, connection, err := p.ListJiraProjects(types.ID(instanceID), types.ID(mattermostUserID)) + metaInfo, connection, err := p.GetJiraProjectMetadata(types.ID(instanceID), types.ID(mattermostUserID)) if err != nil { return respondErr(w, http.StatusInternalServerError, errors.WithMessage(err, "failed to GetProjectMetadata")) } - if len(plist) == 0 { + if len(metaInfo.Projects) == 0 { _, err = respondJSON(w, map[string]interface{}{ "error": "You do not have permission to create issues in any projects. Please contact your Jira admin.", }) @@ -518,24 +523,25 @@ func (p *Plugin) httpGetJiraProjectMetadata(w http.ResponseWriter, r *http.Reque } } - projects := []utils.ReactSelectOption{} - issues := map[string][]utils.ReactSelectOption{} - for _, prj := range plist { - projects = append(projects, utils.ReactSelectOption{ - Value: prj.Key, - Label: prj.Name, - }) - issueTypes := []utils.ReactSelectOption{} - for _, issue := range prj.IssueTypes { - if issue.Subtask { + type option = utils.ReactSelectOption + projects := make([]option, len(metaInfo.Projects)) + issues := make(map[string][]option, len(metaInfo.Projects)) + for index, project := range metaInfo.Projects { + projects[index] = option{ + Value: project.Key, + Label: project.Name, + } + issueTypes := []option{} + for _, issueType := range project.IssueTypes { + if issueType.Subtasks { continue } - issueTypes = append(issueTypes, utils.ReactSelectOption{ - Value: issue.ID, - Label: issue.Name, + issueTypes = append(issueTypes, option{ + Value: issueType.Id, + Label: issueType.Name, }) } - issues[prj.Key] = issueTypes + issues[project.Key] = issueTypes } return respondJSON(w, OutProjectMetadata{ @@ -545,16 +551,16 @@ func (p *Plugin) httpGetJiraProjectMetadata(w http.ResponseWriter, r *http.Reque }) } -func (p *Plugin) ListJiraProjects(instanceID, mattermostUserID types.ID) (jira.ProjectList, *Connection, error) { +func (p *Plugin) GetJiraProjectMetadata(instanceID, mattermostUserID types.ID) (*jira.CreateMetaInfo, *Connection, error) { client, _, connection, err := p.getClient(instanceID, mattermostUserID) if err != nil { return nil, nil, err } - plist, err := client.ListProjects("", -1) + metainfo, err := client.GetCreateMetaInfo(nil) if err != nil { return nil, nil, err } - return plist, connection, nil + return metainfo, connection, nil } var reJiraIssueKey = regexp.MustCompile(`^([[:alnum:]]+)-([[:digit:]]+)$`) @@ -1039,3 +1045,162 @@ func (p *Plugin) getClient(instanceID, mattermostUserID types.ID) (Client, Insta } return client, instance, connection, nil } + +func (p *Plugin) checkIssueWatchers(wh *webhook, instanceID types.ID) { + if !wh.eventTypes.ContainsAny(createdCommentEvent) { + return + } + + jwhook := wh.JiraWebhook + commentAuthor := mdUser(&jwhook.Comment.UpdateAuthor) + commentMessage := fmt.Sprintf("%s **commented** on %s:\n> %s", commentAuthor, jwhook.mdKeySummaryLink(), jwhook.Comment.Body) + client, connection, err := wh.fetchConnectedUser(p, instanceID) + if err != nil || client == nil { + p.errorf("error while fetching connected users for the instanceID %v , Error : %v", instanceID, err) + return + } + + watchers, err := client.GetWatchers(instanceID.String(), wh.Issue.ID, connection) + if err != nil { + p.errorf("error while getting watchers for the issue id %v , err : %v", wh.Issue.ID, err) + return + } + + for _, watcherUser := range watchers.Watchers { + whUserNotification := webhookUserNotification{ + jiraUsername: watcherUser.Name, + jiraAccountID: watcherUser.AccountID, + message: commentMessage, + postType: PostTypeComment, + commentSelf: wh.JiraWebhook.Comment.Self, + notificationType: notificationTypeWatching, + } + + wh.notifications = append(wh.notifications, whUserNotification) + } +} + +func (p *Plugin) applyReporterNotification(wh *webhook, instanceID types.ID, reporter *jira.User) { + if !wh.eventTypes.ContainsAny(createdCommentEvent) { + return + } + + jwhook := wh.JiraWebhook + if reporter == nil || + (reporter.Name != "" && reporter.Name == jwhook.User.Name) || + (reporter.AccountID != "" && reporter.AccountID == jwhook.Comment.UpdateAuthor.AccountID) { + return + } + + commentAuthor := mdUser(&jwhook.Comment.UpdateAuthor) + commentMessage := fmt.Sprintf("%s **commented** on %s:\n> %s", commentAuthor, jwhook.mdKeySummaryLink(), jwhook.Comment.Body) + + connection, err := p.GetUserSetting(wh, instanceID, reporter.Name, reporter.AccountID) + if err != nil || connection.Settings == nil || !connection.Settings.ShouldReceiveNotification(notificationTypeReporter) { + return + } + + wh.notifications = append(wh.notifications, webhookUserNotification{ + jiraUsername: reporter.Name, + jiraAccountID: reporter.AccountID, + message: commentMessage, + postType: PostTypeComment, + commentSelf: jwhook.Comment.Self, + notificationType: notificationTypeReporter, + }) +} + +func (p *Plugin) GetUserSetting(wh *webhook, instanceID types.ID, jiraAccountID, jiraUsername string) (*Connection, error) { + instance, err := p.instanceStore.LoadInstance(instanceID) + if err != nil { + return nil, err + } + + var mattermostUserID types.ID + jiraUserID := jiraAccountID + if jiraUserID == "" { + jiraUserID = jiraUsername + } + + mattermostUserID, err = p.userStore.LoadMattermostUserID(instance.GetID(), jiraUserID) + if err != nil { + return nil, err + } + + connection, err := p.userStore.LoadConnection(instanceID, mattermostUserID) + if err != nil { + return nil, err + } + + return connection, nil +} + +func (s *ConnectionSettings) ShouldReceiveNotification(role string) bool { + if val, ok := s.RolesForDMNotification[role]; ok { + return val + } + + // Check old setting for backwards compatibility + return s.Notifications +} + +func (p *Plugin) fetchConnectedUserFromAccount(account map[string]string, instance Instance) (Client, *Connection, error) { + accountKey := account[jiraUserName] + if account[jiraUserAccountID] != "" { + accountKey = account[jiraUserAccountID] + } + mattermostUserID, err := p.userStore.LoadMattermostUserID(instance.GetID(), accountKey) + if err != nil { + return nil, nil, err + } + + connection, err := p.userStore.LoadConnection(instance.GetID(), mattermostUserID) + if err != nil { + return nil, nil, err + } + + client, err := instance.GetClient(connection) + if err != nil { + return nil, connection, err + } + + return client, connection, nil +} + +func appendAccountInformation(accountID, name string, accountInformation *[]map[string]string) { + *accountInformation = append(*accountInformation, map[string]string{ + jiraUserAccountID: accountID, + jiraUserName: name, + }) +} + +func (wh *webhook) fetchConnectedUser(p *Plugin, instanceID types.ID) (Client, *Connection, error) { + var accountInformation []map[string]string + + if wh.Issue.Fields != nil { + if wh.Issue.Fields.Creator != nil { + appendAccountInformation(wh.Issue.Fields.Creator.AccountID, wh.Issue.Fields.Creator.Name, &accountInformation) + } + if wh.Issue.Fields.Assignee != nil { + appendAccountInformation(wh.Issue.Fields.Assignee.AccountID, wh.Issue.Fields.Assignee.Name, &accountInformation) + } + if wh.Issue.Fields.Reporter != nil { + appendAccountInformation(wh.Issue.Fields.Reporter.AccountID, wh.Issue.Fields.Reporter.Name, &accountInformation) + } + } + + instance, err := p.instanceStore.LoadInstance(instanceID) + if err != nil { + return nil, nil, err + } + for _, account := range accountInformation { + client, connection, err := p.fetchConnectedUserFromAccount(account, instance) + if err != nil { + continue + } + + return client, connection, nil + } + + return nil, nil, nil +} diff --git a/server/issue_test.go b/server/issue_test.go index 0920912cd..0296d67fb 100644 --- a/server/issue_test.go +++ b/server/issue_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/mattermost/mattermost-plugin-jira/server/utils/kvstore" + "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) const ( @@ -83,16 +84,23 @@ func (client testClient) AddComment(issueKey string, comment *jira.Comment) (*ji return nil, nil } +func setupTestPlugin(api *plugintest.API) *Plugin { + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() + + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + p := &Plugin{} + p.SetAPI(api) + p.instanceStore = p.getMockInstanceStoreKV(1) + p.userStore = getMockUserStoreKV() + return p +} func TestTransitionJiraIssue(t *testing.T) { api := &plugintest.API{} api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) - p := Plugin{} - p.SetAPI(api) - p.userStore = getMockUserStoreKV() - p.instanceStore = p.getMockInstanceStoreKV(1) + p := setupTestPlugin(api) - tests := map[string]struct { + for name, tt := range map[string]struct { issueKey string toState string expectedMsg string @@ -128,9 +136,7 @@ func TestTransitionJiraIssue(t *testing.T) { expectedMsg: fmt.Sprintf("[%s](%s/browse/%s) transitioned to `In Progress`", existingIssueKey, mockInstance1URL, existingIssueKey), expectedErr: nil, }, - } - - for name, tt := range tests { + } { t.Run(name, func(t *testing.T) { actual, err := p.TransitionIssue(&InTransitionIssue{ InstanceID: testInstance1.InstanceID, @@ -148,19 +154,10 @@ func TestTransitionJiraIssue(t *testing.T) { func TestRouteIssueTransition(t *testing.T) { api := &plugintest.API{} - - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) - - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) + p := setupTestPlugin(api) - p := Plugin{} - p.SetAPI(api) - - p.userStore = getMockUserStoreKV() - - tests := map[string]struct { + for name, tt := range map[string]struct { bb []byte request *model.PostActionIntegrationRequest expectedCode int @@ -188,8 +185,7 @@ func TestRouteIssueTransition(t *testing.T) { }, expectedCode: http.StatusInternalServerError, }, - } - for name, tt := range tests { + } { t.Run(name, func(t *testing.T) { bb, err := json.Marshal(tt.request) assert.Nil(t, err) @@ -205,17 +201,12 @@ func TestRouteIssueTransition(t *testing.T) { func TestRouteShareIssuePublicly(t *testing.T) { validUserID := "1" api := &plugintest.API{} - p := Plugin{} api.On("SendEphemeralPost", mock.Anything, mock.Anything).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) api.On("CreatePost", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, nil) api.On("DeleteEphemeralPost", validUserID, "").Return() - p.SetAPI(api) - p.instanceStore = p.getMockInstanceStoreKV(1) - p.userStore = getMockUserStoreKV() + p := setupTestPlugin(api) - tests := map[string]struct { + for name, tt := range map[string]struct { bb []byte request *model.PostActionIntegrationRequest expectedCode int @@ -265,8 +256,7 @@ func TestRouteShareIssuePublicly(t *testing.T) { }, expectedCode: http.StatusOK, }, - } - for name, tt := range tests { + } { t.Run(name, func(t *testing.T) { bb, err := json.Marshal(tt.request) assert.Nil(t, err) @@ -279,12 +269,218 @@ func TestRouteShareIssuePublicly(t *testing.T) { } } -func TestRouteAttachCommentToIssue(t *testing.T) { - api := &plugintest.API{} +func TestShouldReceiveNotification(t *testing.T) { + cs := ConnectionSettings{} + cs.RolesForDMNotification = make(map[string]bool) + cs.RolesForDMNotification[assigneeRole] = true + cs.RolesForDMNotification[mentionRole] = true + cs.RolesForDMNotification[reporterRole] = false + cs.RolesForDMNotification[watchingRole] = false + cs.Notifications = true + for name, tt := range map[string]struct { + role string + notification bool + }{ + assigneeRole: { + role: assigneeRole, + notification: true, + }, + mentionRole: { + role: mentionRole, + notification: true, + }, + reporterRole: { + role: reporterRole, + notification: false, + }, + watchingRole: { + role: watchingRole, + notification: false, + }, + "No Role": { + role: "", + notification: true, + }, + } { + t.Run(name, func(t *testing.T) { + val := cs.ShouldReceiveNotification(tt.role) + assert.Equal(t, tt.notification, val) + }) + } +} + +func TestFetchConnectedUser(t *testing.T) { + p := setupTestPlugin(&plugintest.API{}) + + for name, tt := range map[string]struct { + instanceID types.ID + client Client + connection *Connection + wh webhook + expectedErr error + }{ + "Success": { + instanceID: testInstance1.InstanceID, + client: testClient{}, + connection: &Connection{ + Settings: &ConnectionSettings{ + Notifications: true, + RolesForDMNotification: map[string]bool{ + assigneeRole: true, + mentionRole: true, + reporterRole: true, + watchingRole: true, + }, + }, + User: jira.User{ + AccountID: "test-AccountID", + }, + }, + wh: webhook{ + JiraWebhook: &JiraWebhook{ + Issue: jira.Issue{ + Fields: &jira.IssueFields{ + Creator: &jira.User{}, + }, + }, + }, + }, + expectedErr: nil, + }, + "Issue Field not found": { + instanceID: testInstance1.InstanceID, + client: nil, + connection: nil, + wh: webhook{ + JiraWebhook: &JiraWebhook{ + Issue: jira.Issue{}, + }, + }, + expectedErr: nil, + }, + "Unable to load instance": { + instanceID: "test-instanceID", + client: nil, + connection: nil, + wh: webhook{ + JiraWebhook: &JiraWebhook{ + Issue: jira.Issue{ + Fields: &jira.IssueFields{ + Creator: &jira.User{}, + }, + }, + }, + }, + expectedErr: errors.New(fmt.Sprintf("instance %q not found", "test-instanceID")), + }, + } { + t.Run(name, func(t *testing.T) { + client, connection, error := tt.wh.fetchConnectedUser(p, tt.instanceID) + assert.Equal(t, tt.connection, connection) + assert.Equal(t, tt.client, client) + if tt.expectedErr != nil { + assert.Error(t, tt.expectedErr, error) + } + }) + } +} - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) +func TestApplyReporterNotification(t *testing.T) { + p := setupTestPlugin(&plugintest.API{}) - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + wh := &webhook{ + eventTypes: map[string]bool{createdCommentEvent: true}, + JiraWebhook: &JiraWebhook{ + Comment: jira.Comment{ + UpdateAuthor: jira.User{}, + }, + Issue: jira.Issue{ + Key: "test-key", + Fields: &jira.IssueFields{ + Type: jira.IssueType{ + Name: "Story", + }, + Summary: "", + }, + Self: "test-self", + }, + }, + } + for name, tt := range map[string]struct { + instanceID types.ID + reporter *jira.User + totalNotifications int + }{ + "Success": { + instanceID: testInstance1.InstanceID, + reporter: &jira.User{}, + totalNotifications: 1, + }, + "Unable to load instance": { + instanceID: "test-instanceID", + reporter: &jira.User{}, + }, + "Reporter is nil": { + instanceID: testInstance1.InstanceID, + reporter: nil, + }, + } { + t.Run(name, func(t *testing.T) { + wh.notifications = []webhookUserNotification{} + p.applyReporterNotification(wh, tt.instanceID, tt.reporter) + assert.Equal(t, len(wh.notifications), tt.totalNotifications) + }) + } +} + +func TestGetUserSetting(t *testing.T) { + p := setupTestPlugin(&plugintest.API{}) + + jiraAccountID := "test-jiraAccountID" + jiraUsername := "test-jiraUsername" + + for name, tt := range map[string]struct { + wh *webhook + instanceID types.ID + connection *Connection + expectedErr error + }{ + "Success": { + wh: &webhook{}, + instanceID: testInstance1.InstanceID, + connection: &Connection{ + User: jira.User{AccountID: "test-AccountID"}, + Settings: &ConnectionSettings{ + Notifications: true, + RolesForDMNotification: (map[string]bool{ + assigneeRole: true, + mentionRole: true, + reporterRole: true, + watchingRole: true, + }), + }, + }, + expectedErr: nil, + }, + "Unable to load instance": { + wh: &webhook{}, + instanceID: "instanceID", + connection: nil, + expectedErr: errors.New("instance " + fmt.Sprintf("\"%s\"", "instanceID") + " not found"), + }, + } { + t.Run(name, func(t *testing.T) { + connection, error := p.GetUserSetting(tt.wh, tt.instanceID, jiraAccountID, jiraUsername) + assert.Equal(t, tt.connection, connection) + if tt.expectedErr != nil { + assert.Error(t, tt.expectedErr, error) + } + }) + } +} + +func TestRouteAttachCommentToIssue(t *testing.T) { + api := &plugintest.API{} api.On("GetPost", "error_post").Return(nil, &model.AppError{Id: "1"}) api.On("GetPost", "post_not_found").Return(nil, (*model.AppError)(nil)) @@ -299,6 +495,11 @@ func TestRouteAttachCommentToIssue(t *testing.T) { api.On("PublishWebSocketEvent", "update_defaults", mock.AnythingOfType("map[string]interface {}"), mock.AnythingOfType("*model.WebsocketBroadcast")) + p := setupTestPlugin(api) + p.updateConfig(func(conf *config) { + conf.mattermostSiteURL = "https://somelink.com" + }) + type requestStruct struct { PostID string `json:"post_id"` InstanceID string `json:"instance_id"` @@ -306,7 +507,7 @@ func TestRouteAttachCommentToIssue(t *testing.T) { IssueKey string `json:"issueKey"` } - tests := map[string]struct { + for name, tt := range map[string]struct { method string header string request *requestStruct @@ -381,17 +582,8 @@ func TestRouteAttachCommentToIssue(t *testing.T) { }, expectedCode: http.StatusOK, }, - } - for name, tt := range tests { + } { t.Run(name, func(t *testing.T) { - p := Plugin{} - p.SetAPI(api) - p.updateConfig(func(conf *config) { - conf.mattermostSiteURL = "https://somelink.com" - }) - p.userStore = getMockUserStoreKV() - p.instanceStore = p.getMockInstanceStoreKV(1) - tt.request.InstanceID = testInstance1.InstanceID.String() bb, err := json.Marshal(tt.request) assert.Nil(t, err) diff --git a/server/kv_mock_test.go b/server/kv_mock_test.go index 51b142f94..a57d7a2f7 100644 --- a/server/kv_mock_test.go +++ b/server/kv_mock_test.go @@ -79,10 +79,14 @@ func (store mockUserStore) StoreConnection(types.ID, types.ID, *Connection) erro return nil } func (store mockUserStore) LoadConnection(types.ID, types.ID) (*Connection, error) { - return &Connection{}, nil + return &Connection{ + Settings: &ConnectionSettings{ + Notifications: true, + }, + }, nil } func (store mockUserStore) LoadMattermostUserID(instanceID types.ID, jiraUserName string) (types.ID, error) { - return "testMattermostUserId012345", nil + return mockUserIDWithNotifications, nil } func (store mockUserStore) DeleteConnection(instanceID, mattermostUserID types.ID) error { return nil diff --git a/server/plugin.go b/server/plugin.go index a60cd9faa..0e883f9b8 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -351,13 +351,12 @@ func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { return fmt.Errorf("unable to get jira client for server: %w", err) } - plist, err := jiraCloudClient{JiraClient{Jira: client}}.ListProjects("", -1) + keys, err := JiraClient{Jira: client}.GetAllProjectKeys() if err != nil { return fmt.Errorf("unable to get project keys: %w", err) } - for _, proj := range plist { - key := proj.Key + for _, key := range keys { err = p.AddAutolinks(key, ci.BaseURL) } if err != nil { diff --git a/server/plugin_test.go b/server/plugin_test.go index 80f4975c3..a59ce89c4 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -109,9 +109,9 @@ func TestPlugin(t *testing.T) { t.Run(name, func(t *testing.T) { api := &plugintest.API{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("KVGet", mock.AnythingOfTypeArgument("string")).Return(make([]byte, 0), (*model.AppError)(nil)) api.On("GetDirectChannel", mockAnythingOfTypeBatch("string", 2)...).Return( diff --git a/server/settings.go b/server/settings.go index a3fa3dfe2..95ea868cd 100644 --- a/server/settings.go +++ b/server/settings.go @@ -2,6 +2,8 @@ package main import ( "github.com/mattermost/mattermost-server/v6/model" + "golang.org/x/text/cases" + "golang.org/x/text/language" "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) @@ -9,17 +11,36 @@ import ( const ( settingOn = "on" settingOff = "off" + + errStoreNewSettings = "Could not store new settings. Please contact your system administrator. Error: %v" + errConnectToJira = "Your account is not connected to Jira. Please type `/jira connect`. %v" + + assigneeRole = "assignee" + mentionRole = "mention" + reporterRole = "reporter" + watchingRole = "watching" ) +func (connection *Connection) updateRolesForDMNotification(role string, hasNotification bool) bool { + if role != assigneeRole && role != mentionRole && role != reporterRole && role != watchingRole { + return false + } + if connection.Settings.RolesForDMNotification == nil { + connection.Settings.RolesForDMNotification = make(map[string]bool) + } + connection.Settings.RolesForDMNotification[role] = hasNotification + return true +} func (p *Plugin) settingsNotifications(header *model.CommandArgs, instanceID, mattermostUserID types.ID, connection *Connection, args []string) *model.CommandResponse { - const helpText = "`/jira settings notifications [value]`\n* Invalid value. Accepted values are: `on` or `off`." + const helpText = "`/jira settings notifications [assignee|mention|reporter|watching] [value]`\n* Invalid value. Accepted values are: `on` or `off`." - if len(args) != 2 { + if len(args) != 3 { return p.responsef(header, helpText) } + role, roleStatus := args[1], args[2] var value bool - switch args[1] { + switch roleStatus { case settingOn: value = true case settingOff: @@ -31,21 +52,24 @@ func (p *Plugin) settingsNotifications(header *model.CommandArgs, instanceID, ma if connection.Settings == nil { connection.Settings = &ConnectionSettings{} } - connection.Settings.Notifications = value + if !connection.updateRolesForDMNotification(role, value) { + return p.responsef(header, helpText) + } + if err := p.userStore.StoreConnection(instanceID, mattermostUserID, connection); err != nil { p.errorf("settingsNotifications, err: %v", err) - p.responsef(header, "Could not store new settings. Please contact your system administrator. error: %v", err) + p.responsef(header, errStoreNewSettings, err) } // send back the actual value updatedConnection, err := p.userStore.LoadConnection(instanceID, mattermostUserID) if err != nil { - return p.responsef(header, "Your username is not connected to Jira. Please type `jira connect`. %v", err) + return p.responsef(header, errConnectToJira, err) } notifications := settingOff - if updatedConnection.Settings.Notifications { + if updatedConnection.Settings.RolesForDMNotification[role] { notifications = settingOn } - return p.responsef(header, "Settings updated. Notifications %s.", notifications) + return p.responsef(header, "Settings updated.\n\t%s notifications %s.", cases.Title(language.Und, cases.NoLower).String(role), notifications) } diff --git a/server/subscribe_test.go b/server/subscribe_test.go index c9ce769a4..f5397d234 100644 --- a/server/subscribe_test.go +++ b/server/subscribe_test.go @@ -1380,8 +1380,8 @@ func TestGetChannelsSubscribed(t *testing.T) { r := bytes.NewReader(data) bb, err := ioutil.ReadAll(r) - require.Nil(t, err) + require.Nil(t, err) wh, err := ParseWebhook(bb) assert.Nil(t, err) diff --git a/server/user.go b/server/user.go index 4b499f8ee..0714d4da8 100644 --- a/server/user.go +++ b/server/user.go @@ -34,24 +34,42 @@ type Connection struct { DefaultProjectKey string `json:"default_project_key,omitempty"` } -func (c *Connection) JiraAccountID() types.ID { - if c.AccountID != "" { - return types.ID(c.AccountID) +func (connection *Connection) JiraAccountID() types.ID { + if connection.AccountID != "" { + return types.ID(connection.AccountID) } - return types.ID(c.Name) + return types.ID(connection.Name) } type ConnectionSettings struct { - Notifications bool `json:"notifications"` + Notifications bool `json:"notifications"` + RolesForDMNotification map[string]bool } func (s *ConnectionSettings) String() string { - notifications := "off" - if s != nil && s.Notifications { - notifications = "on" + assigneeNotifications := "Notifications for assignee: off" + mentionNotifications := "Notifications for mention: off" + reporterNotifications := "Notifications for reporter: off" + watchingNotifications := "Notifications for watching: off" + + if s != nil && s.ShouldReceiveNotification(assigneeRole) { + assigneeNotifications = "Notifications for assignee: on" + } + + if s != nil && s.ShouldReceiveNotification(mentionRole) { + mentionNotifications = "Notifications for mention: on" + } + + if s != nil && s.ShouldReceiveNotification(reporterRole) { + reporterNotifications = "Notifications for reporter: on" + } + + if s != nil && s.ShouldReceiveNotification(watchingRole) { + watchingNotifications = "Notifications for watching: on" } - return fmt.Sprintf("\tNotifications: %s", notifications) + + return fmt.Sprintf("\t- %s \n\t- %s \n\t- %s \n\t- %s", assigneeNotifications, mentionNotifications, reporterNotifications, watchingNotifications) } func NewUser(mattermostUserID types.ID) *User { diff --git a/server/user_cloud.go b/server/user_cloud.go index 4fdd53832..f3b722a09 100644 --- a/server/user_cloud.go +++ b/server/user_cloud.go @@ -103,6 +103,12 @@ func (p *Plugin) httpACUserInteractive(w http.ResponseWriter, r *http.Request, i // Set default settings the first time a user connects Settings: &ConnectionSettings{ Notifications: true, + RolesForDMNotification: map[string]bool{ + mentionRole: true, + assigneeRole: true, + reporterRole: true, + watchingRole: true, + }, }, } diff --git a/server/user_server.go b/server/user_server.go index f5fa2b9fb..0d8b44399 100644 --- a/server/user_server.go +++ b/server/user_server.go @@ -102,9 +102,16 @@ func (p *Plugin) httpOAuth1aComplete(w http.ResponseWriter, r *http.Request, ins return http.StatusInternalServerError, err } connection.User = *juser - // Set default settings the first time a user connects - connection.Settings = &ConnectionSettings{Notifications: true} + connection.Settings = &ConnectionSettings{ + Notifications: true, + RolesForDMNotification: map[string]bool{ + mentionRole: true, + assigneeRole: true, + reporterRole: true, + watchingRole: true, + }, + } err = p.connectUser(instance, types.ID(mattermostUserID), connection) if err != nil { diff --git a/server/user_test.go b/server/user_test.go index b26761de6..c32c77d8e 100644 --- a/server/user_test.go +++ b/server/user_test.go @@ -16,12 +16,28 @@ func TestUserSettings_String(t *testing.T) { expectedOutput string }{ "notifications on": { - settings: ConnectionSettings{Notifications: false}, - expectedOutput: "\tNotifications: off", + settings: ConnectionSettings{ + Notifications: true, + RolesForDMNotification: map[string]bool{ + assigneeRole: true, + mentionRole: true, + reporterRole: true, + watchingRole: true, + }, + }, + expectedOutput: "\t- Notifications for assignee: on \n\t- Notifications for mention: on \n\t- Notifications for reporter: on \n\t- Notifications for watching: on", }, "notifications off": { - settings: ConnectionSettings{Notifications: true}, - expectedOutput: "\tNotifications: on", + settings: ConnectionSettings{ + Notifications: false, + RolesForDMNotification: map[string]bool{ + assigneeRole: false, + mentionRole: false, + reporterRole: false, + watchingRole: false, + }, + }, + expectedOutput: "\t- Notifications for assignee: off \n\t- Notifications for mention: off \n\t- Notifications for reporter: off \n\t- Notifications for watching: off", }, } for name, tt := range tests { @@ -41,9 +57,9 @@ func TestRouteUserStart(t *testing.T) { } api := &plugintest.API{} - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() p := Plugin{} p.SetAPI(api) diff --git a/server/webhook.go b/server/webhook.go index f684dea0f..a2c606c20 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -46,11 +46,12 @@ type webhook struct { } type webhookUserNotification struct { - jiraUsername string - jiraAccountID string - message string - postType string - commentSelf string + jiraUsername string + jiraAccountID string + message string + postType string + commentSelf string + notificationType string } func (wh *webhook) Events() StringSet { @@ -101,7 +102,6 @@ func (wh *webhook) PostNotifications(p *Plugin, instanceID types.ID) ([]*model.P if len(wh.notifications) == 0 { return nil, http.StatusOK, nil } - // We will only send webhook events if we have a connected instance. instance, err := p.instanceStore.LoadInstance(instanceID) if err != nil { @@ -110,6 +110,7 @@ func (wh *webhook) PostNotifications(p *Plugin, instanceID types.ID) ([]*model.P } posts := []*model.Post{} + var mapForNotification = make(map[types.ID]int) for _, notification := range wh.notifications { var mattermostUserID types.ID var err error @@ -137,7 +138,14 @@ func (wh *webhook) PostNotifications(p *Plugin, instanceID types.ID) ([]*model.P } // If this is a comment-related webhook, we need to check if they have permissions to read that. // Otherwise, check if they can view the issue. - + if !c.Settings.ShouldReceiveNotification(notification.notificationType) { + continue + } + if _, ok := mapForNotification[mattermostUserID]; ok { + continue + } else { + mapForNotification[mattermostUserID] = 1 + } isCommentEvent := wh.Events().Intersection(commentEvents).Len() > 0 if isCommentEvent { err = client.RESTGet(notification.commentSelf, nil, &struct{}{}) diff --git a/server/webhook_http_test.go b/server/webhook_http_test.go index 133f2a034..077ebbc73 100644 --- a/server/webhook_http_test.go +++ b/server/webhook_http_test.go @@ -603,9 +603,9 @@ func TestWebhookHTTP(t *testing.T) { t.Run(name, func(t *testing.T) { api := &plugintest.API{} - api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return(nil) - api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return(nil) + api.On("LogDebug", mockAnythingOfTypeBatch("string", 11)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 10)...).Return() + api.On("LogError", mockAnythingOfTypeBatch("string", 13)...).Return() api.On("GetUserByUsername", "theuser").Return(&model.User{ Id: "theuserid", diff --git a/server/webhook_parser.go b/server/webhook_parser.go index e9d5985b6..ee91e10f5 100644 --- a/server/webhook_parser.go +++ b/server/webhook_parser.go @@ -277,9 +277,10 @@ func appendCommentNotifications(wh *webhook, verb string) { } notification := webhookUserNotification{ - message: message, - postType: PostTypeMention, - commentSelf: jwh.Comment.Self, + message: message, + postType: PostTypeMention, + commentSelf: jwh.Comment.Self, + notificationType: "mention", } if isAccountID { @@ -301,11 +302,12 @@ func appendCommentNotifications(wh *webhook, verb string) { } wh.notifications = append(wh.notifications, webhookUserNotification{ - jiraUsername: jwh.Issue.Fields.Assignee.Name, - jiraAccountID: jwh.Issue.Fields.Assignee.AccountID, - message: fmt.Sprintf("%s **commented** on %s:\n>%s", commentAuthor, jwh.mdKeySummaryLink(), jwh.Comment.Body), - postType: PostTypeComment, - commentSelf: jwh.Comment.Self, + jiraUsername: jwh.Issue.Fields.Assignee.Name, + jiraAccountID: jwh.Issue.Fields.Assignee.AccountID, + message: fmt.Sprintf("%s **commented** on %s:\n>%s", commentAuthor, jwh.mdKeySummaryLink(), jwh.Comment.Body), + postType: PostTypeComment, + commentSelf: jwh.Comment.Self, + notificationType: "assignee", }) } @@ -382,9 +384,10 @@ func appendNotificationForAssignee(wh *webhook) { } wh.notifications = append(wh.notifications, webhookUserNotification{ - jiraUsername: jwh.Issue.Fields.Assignee.Name, - jiraAccountID: jwh.Issue.Fields.Assignee.AccountID, - message: fmt.Sprintf("%s **assigned** you to %s", jwh.mdUser(), jwh.mdKeySummaryLink()), + jiraUsername: jwh.Issue.Fields.Assignee.Name, + jiraAccountID: jwh.Issue.Fields.Assignee.AccountID, + message: fmt.Sprintf("%s **assigned** you to %s", jwh.mdUser(), jwh.mdKeySummaryLink()), + notificationType: "assignee", }) } diff --git a/server/webhook_worker.go b/server/webhook_worker.go index a008e665f..d6938a682 100644 --- a/server/webhook_worker.go +++ b/server/webhook_worker.go @@ -39,16 +39,18 @@ func (ww webhookWorker) process(msg *webhookMessage) (err error) { if err != nil { return err } - - if _, _, err = wh.PostNotifications(ww.p, msg.InstanceID); err != nil { - ww.p.errorf("WebhookWorker id: %d, error posting notifications, err: %v", ww.id, err) - } - v := wh.(*webhook) if err = v.JiraWebhook.expandIssue(ww.p, msg.InstanceID); err != nil { return err } + ww.p.checkIssueWatchers(v, msg.InstanceID) + ww.p.applyReporterNotification(v, msg.InstanceID, v.Issue.Fields.Reporter) + + if _, _, err = wh.PostNotifications(ww.p, msg.InstanceID); err != nil { + ww.p.errorf("WebhookWorker id: %d, error posting notifications, err: %v", ww.id, err) + } + channelsSubscribed, err := ww.p.getChannelsSubscribed(v, msg.InstanceID) if err != nil { return err