summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChris <ccbrown112@gmail.com>2017-08-02 01:36:54 -0700
committerGitHub <noreply@github.com>2017-08-02 01:36:54 -0700
commit65817e13c7900ea81947e40e177459cfea8acee4 (patch)
tree8dfc88db36844e4186b4110753be8de976da6371
parentc6bf235ec2a8613d8ef35607e2aeb8c0cb629f45 (diff)
downloadchat-65817e13c7900ea81947e40e177459cfea8acee4.tar.gz
chat-65817e13c7900ea81947e40e177459cfea8acee4.tar.bz2
chat-65817e13c7900ea81947e40e177459cfea8acee4.zip
PLT-6965 jira integration (plus plugin scaffolding) (#6918)
* plugin scaffolding / jira integration * add vendored testify packages * webhook fix * don't change i18n ids * support configuration watching * add basic jira plugin configuration to admin console * fix eslint errors * fix another eslint warning * polish * undo unintentional config.json commit >:( * test fix * add jira plugin diagnostics, remove dm support, add bot tag, generate web-safe secrets * rebase, implement requested changes * requested changes * remove tests and minimize makefile change * add missing license headers * add missing comma * remove bad line from Makefile
-rw-r--r--Makefile2
-rw-r--r--app/channel.go16
-rw-r--r--app/diagnostics.go23
-rw-r--r--app/diagnostics_test.go19
-rw-r--r--app/plugin/api.go37
-rw-r--r--app/plugin/base.go9
-rw-r--r--app/plugin/hooks.go10
-rw-r--r--app/plugin/jira/configuration.go10
-rw-r--r--app/plugin/jira/plugin.go78
-rw-r--r--app/plugin/jira/webhook.go168
-rw-r--r--app/plugin/plugin.go9
-rw-r--r--app/plugins.go86
-rw-r--r--app/webhook.go37
-rw-r--r--cmd/platform/server.go1
-rw-r--r--config/config.json5
-rw-r--r--model/config.go9
-rw-r--r--webapp/components/admin_console/admin_sidebar.jsx9
-rw-r--r--webapp/components/admin_console/generated_setting.jsx13
-rw-r--r--webapp/components/suggestion/suggestion_box.jsx49
-rwxr-xr-xwebapp/i18n/en.json14
-rw-r--r--webapp/plugins/jira/components/settings.jsx251
-rw-r--r--webapp/plugins/jira/components/style.scss60
-rw-r--r--webapp/routes/route_admin_console.jsx5
-rw-r--r--webapp/sass/routes/_admin-console.scss4
24 files changed, 891 insertions, 33 deletions
diff --git a/Makefile b/Makefile
index 026a76582..2af45e5f6 100644
--- a/Makefile
+++ b/Makefile
@@ -539,6 +539,8 @@ govet:
$(GO) vet $(GOFLAGS) ./api || exit 1
$(GO) vet $(GOFLAGS) ./api4 || exit 1
$(GO) vet $(GOFLAGS) ./app || exit 1
+ $(GO) vet $(GOFLAGS) ./app/plugin || exit 1
+ $(GO) vet $(GOFLAGS) ./app/plugin/jira || exit 1
$(GO) vet $(GOFLAGS) ./cmd/platform || exit 1
$(GO) vet $(GOFLAGS) ./einterfaces || exit 1
$(GO) vet $(GOFLAGS) ./jobs || exit 1
diff --git a/app/channel.go b/app/channel.go
index 03df0e800..4a0f94b42 100644
--- a/app/channel.go
+++ b/app/channel.go
@@ -1206,3 +1206,19 @@ func GetPinnedPosts(channelId string) (*model.PostList, *model.AppError) {
return result.Data.(*model.PostList), nil
}
}
+
+func GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) {
+ result := <-Srv.Store.Channel().GetByName("", model.GetDMNameFromIds(userId1, userId2), true)
+ if result.Err != nil && result.Err.Id == store.MISSING_CHANNEL_ERROR {
+ result := <-Srv.Store.Channel().CreateDirectChannel(userId1, userId2)
+ if result.Err != nil {
+ return nil, model.NewAppError("GetOrCreateDMChannel", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, http.StatusBadRequest)
+ }
+ InvalidateCacheForUser(userId1)
+ InvalidateCacheForUser(userId2)
+ return result.Data.(*model.Channel), nil
+ } else if result.Err != nil {
+ return nil, model.NewAppError("GetOrCreateDMChannel", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode)
+ }
+ return result.Data.(*model.Channel), nil
+}
diff --git a/app/diagnostics.go b/app/diagnostics.go
index 603ceb8a5..a22e87587 100644
--- a/app/diagnostics.go
+++ b/app/diagnostics.go
@@ -4,6 +4,7 @@
package app
import (
+ "encoding/json"
"log"
"os"
"runtime"
@@ -38,6 +39,7 @@ const (
TRACK_CONFIG_ANALYTICS = "config_analytics"
TRACK_CONFIG_ANNOUNCEMENT = "config_announcement"
TRACK_CONFIG_ELASTICSEARCH = "config_elasticsearch"
+ TRACK_CONFIG_PLUGIN = "config_plugin"
TRACK_ACTIVITY = "activity"
TRACK_LICENSE = "license"
@@ -87,6 +89,23 @@ func isDefault(setting interface{}, defaultValue interface{}) bool {
return false
}
+func pluginSetting(plugin, key string, defaultValue interface{}) interface{} {
+ settings, ok := utils.Cfg.PluginSettings.Plugins[plugin]
+ if !ok {
+ return defaultValue
+ }
+ var m map[string]interface{}
+ if b, err := json.Marshal(settings); err != nil {
+ return defaultValue
+ } else {
+ json.Unmarshal(b, &m)
+ }
+ if value, ok := m[key]; ok {
+ return value
+ }
+ return defaultValue
+}
+
func trackActivity() {
var userCount int64
var activeUserCount int64
@@ -393,6 +412,10 @@ func trackConfig() {
"enable_searching": *utils.Cfg.ElasticsearchSettings.EnableSearching,
"sniff": *utils.Cfg.ElasticsearchSettings.Sniff,
})
+
+ SendDiagnostic(TRACK_CONFIG_PLUGIN, map[string]interface{}{
+ "enable_jira": pluginSetting("jira", "enabled", false),
+ })
}
func trackLicense() {
diff --git a/app/diagnostics_test.go b/app/diagnostics_test.go
index 80c12fd2d..57e9eaf79 100644
--- a/app/diagnostics_test.go
+++ b/app/diagnostics_test.go
@@ -28,6 +28,24 @@ func newTestServer() (chan string, *httptest.Server) {
return result, server
}
+func TestPluginSetting(t *testing.T) {
+ before := utils.Cfg.PluginSettings.Plugins
+ utils.Cfg.PluginSettings.Plugins = map[string]interface{}{
+ "test": map[string]string{
+ "foo": "bar",
+ },
+ }
+ defer func() {
+ utils.Cfg.PluginSettings.Plugins = before
+ }()
+ if pluginSetting("test", "foo", "asd") != "bar" {
+ t.Fatal()
+ }
+ if pluginSetting("test", "qwe", "asd") != "asd" {
+ t.Fatal()
+ }
+}
+
func TestDiagnostics(t *testing.T) {
Setup().InitBasic()
@@ -116,6 +134,7 @@ func TestDiagnostics(t *testing.T) {
TRACK_CONFIG_SUPPORT,
TRACK_CONFIG_NATIVEAPP,
TRACK_CONFIG_ANALYTICS,
+ TRACK_CONFIG_PLUGIN,
TRACK_ACTIVITY,
TRACK_SERVER,
} {
diff --git a/app/plugin/api.go b/app/plugin/api.go
new file mode 100644
index 000000000..ceea51969
--- /dev/null
+++ b/app/plugin/api.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package plugin
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/mattermost/platform/model"
+)
+
+type API interface {
+ // Loads the plugin's configuration
+ LoadPluginConfiguration(dest interface{}) error
+
+ // The plugin's router
+ PluginRouter() *mux.Router
+
+ // Gets a team by its name
+ GetTeamByName(name string) (*model.Team, *model.AppError)
+
+ // Gets a user by its name
+ GetUserByName(name string) (*model.User, *model.AppError)
+
+ // Gets a channel by its name
+ GetChannelByName(teamId, name string) (*model.Channel, *model.AppError)
+
+ // Gets a direct message channel
+ GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError)
+
+ // Creates a post
+ CreatePost(post *model.Post, teamId string) (*model.Post, *model.AppError)
+
+ // Returns a localized string. If a request is given, its headers will be used to pick a locale.
+ I18n(id string, r *http.Request) string
+}
diff --git a/app/plugin/base.go b/app/plugin/base.go
new file mode 100644
index 000000000..3ad696a77
--- /dev/null
+++ b/app/plugin/base.go
@@ -0,0 +1,9 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package plugin
+
+// Base provides default implementations for hooks.
+type Base struct{}
+
+func (b *Base) OnConfigurationChange() {}
diff --git a/app/plugin/hooks.go b/app/plugin/hooks.go
new file mode 100644
index 000000000..31eac0710
--- /dev/null
+++ b/app/plugin/hooks.go
@@ -0,0 +1,10 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package plugin
+
+// All implementations should be safe for concurrent use.
+type Hooks interface {
+ // Invoked when configuration changes may have been made
+ OnConfigurationChange()
+}
diff --git a/app/plugin/jira/configuration.go b/app/plugin/jira/configuration.go
new file mode 100644
index 000000000..2afc20282
--- /dev/null
+++ b/app/plugin/jira/configuration.go
@@ -0,0 +1,10 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package jira
+
+type Configuration struct {
+ Enabled bool
+ Secret string
+ UserName string
+}
diff --git a/app/plugin/jira/plugin.go b/app/plugin/jira/plugin.go
new file mode 100644
index 000000000..ad51e723c
--- /dev/null
+++ b/app/plugin/jira/plugin.go
@@ -0,0 +1,78 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package jira
+
+import (
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "sync/atomic"
+
+ l4g "github.com/alecthomas/log4go"
+ "github.com/mattermost/platform/app/plugin"
+ "github.com/mattermost/platform/model"
+)
+
+type Plugin struct {
+ plugin.Base
+ api plugin.API
+ configuration atomic.Value
+}
+
+func (p *Plugin) Initialize(api plugin.API) {
+ p.api = api
+ p.OnConfigurationChange()
+ api.PluginRouter().HandleFunc("/webhook", p.handleWebhook).Methods("POST")
+}
+
+func (p *Plugin) config() *Configuration {
+ return p.configuration.Load().(*Configuration)
+}
+
+func (p *Plugin) OnConfigurationChange() {
+ var configuration Configuration
+ if err := p.api.LoadPluginConfiguration(&configuration); err != nil {
+ l4g.Error(err.Error())
+ }
+ p.configuration.Store(&configuration)
+}
+
+func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
+ config := p.config()
+ if !config.Enabled || config.Secret == "" || config.UserName == "" {
+ http.Error(w, "This plugin is not configured.", http.StatusForbidden)
+ return
+ } else if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(config.Secret)) != 1 {
+ http.Error(w, "You must provide the configured secret.", http.StatusForbidden)
+ return
+ }
+
+ var webhook Webhook
+
+ if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ } else if attachment, err := webhook.SlackAttachment(); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ } else if attachment == nil {
+ return
+ } else if r.URL.Query().Get("channel") == "" {
+ http.Error(w, "You must provide a channel.", http.StatusBadRequest)
+ } else if user, err := p.api.GetUserByName(config.UserName); err != nil {
+ http.Error(w, p.api.I18n(err.Message, r), err.StatusCode)
+ } else if team, err := p.api.GetTeamByName(r.URL.Query().Get("team")); err != nil {
+ http.Error(w, p.api.I18n(err.Message, r), err.StatusCode)
+ } else if channel, err := p.api.GetChannelByName(team.Id, r.URL.Query().Get("channel")); err != nil {
+ http.Error(w, p.api.I18n(err.Message, r), err.StatusCode)
+ } else if _, err := p.api.CreatePost(&model.Post{
+ ChannelId: channel.Id,
+ Type: model.POST_SLACK_ATTACHMENT,
+ UserId: user.Id,
+ Props: map[string]interface{}{
+ "from_webhook": "true",
+ "attachments": []*model.SlackAttachment{attachment},
+ },
+ }, channel.TeamId); err != nil {
+ http.Error(w, p.api.I18n(err.Message, r), err.StatusCode)
+ }
+}
diff --git a/app/plugin/jira/webhook.go b/app/plugin/jira/webhook.go
new file mode 100644
index 000000000..c09e46703
--- /dev/null
+++ b/app/plugin/jira/webhook.go
@@ -0,0 +1,168 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package jira
+
+import (
+ "bytes"
+ "net/url"
+ "strings"
+ "text/template"
+
+ "github.com/mattermost/platform/model"
+)
+
+type Webhook struct {
+ WebhookEvent string
+ Issue struct {
+ Self string
+ Key string
+ Fields struct {
+ Assignee *struct {
+ DisplayName string
+ Name string
+ }
+ Summary string
+ Description string
+ Priority *struct {
+ Id string
+ Name string
+ }
+ IssueType struct {
+ Name string
+ IconURL string
+ }
+ Resolution *struct {
+ Id string
+ }
+ Status struct {
+ Id string
+ }
+ }
+ }
+ User struct {
+ Name string
+ AvatarUrls map[string]string
+ DisplayName string
+ }
+ Comment struct {
+ Body string
+ }
+ ChangeLog struct {
+ Items []struct {
+ FromString string
+ ToString string
+ Field string
+ }
+ }
+}
+
+// Returns the text to be placed in the resulting post or an empty string if nothing should be
+// posted.
+func (w *Webhook) SlackAttachment() (*model.SlackAttachment, error) {
+ switch w.WebhookEvent {
+ case "jira:issue_created":
+ case "jira:issue_updated":
+ isResolutionChange := false
+ for _, change := range w.ChangeLog.Items {
+ if change.Field == "resolution" {
+ isResolutionChange = (change.FromString == "") != (change.ToString == "")
+ break
+ }
+ }
+ if !isResolutionChange {
+ return nil, nil
+ }
+ case "jira:issue_deleted":
+ if w.Issue.Fields.Resolution != nil {
+ return nil, nil
+ }
+ default:
+ return nil, nil
+ }
+
+ pretext, err := w.renderText("" +
+ "{{.User.DisplayName}} {{.Verb}} {{.Issue.Fields.IssueType.Name}} " +
+ "[{{.Issue.Key}}]({{.JIRAURL}}/browse/{{.Issue.Key}})" +
+ "")
+ if err != nil {
+ return nil, err
+ }
+
+ text, err := w.renderText("" +
+ "[{{.Issue.Fields.Summary}}]({{.JIRAURL}}/browse/{{.Issue.Key}})" +
+ "{{if eq .WebhookEvent \"jira:issue_created\"}}{{if ne .Issue.Fields.Description \"\"}}" +
+ "\n\n{{.Issue.Fields.Description}}" +
+ "{{end}}{{end}}" +
+ "")
+ if err != nil {
+ return nil, err
+ }
+
+ var fields []*model.SlackAttachmentField
+ if w.WebhookEvent == "jira:issue_created" {
+ if w.Issue.Fields.Assignee != nil {
+ fields = append(fields, &model.SlackAttachmentField{
+ Title: "Assignee",
+ Value: w.Issue.Fields.Assignee.DisplayName,
+ Short: true,
+ })
+ }
+ if w.Issue.Fields.Priority != nil {
+ fields = append(fields, &model.SlackAttachmentField{
+ Title: "Priority",
+ Value: w.Issue.Fields.Priority.Name,
+ Short: true,
+ })
+ }
+ }
+
+ return &model.SlackAttachment{
+ Fallback: pretext,
+ Color: "#95b7d0",
+ Pretext: pretext,
+ Text: text,
+ Fields: fields,
+ }, nil
+}
+
+func (w *Webhook) renderText(tplBody string) (string, error) {
+ issueSelf, err := url.Parse(w.Issue.Self)
+ if err != nil {
+ return "", err
+ }
+ jiraURL := strings.TrimRight(issueSelf.ResolveReference(&url.URL{Path: "/"}).String(), "/")
+ verb := strings.TrimPrefix(w.WebhookEvent, "jira:issue_")
+
+ if w.WebhookEvent == "jira:issue_updated" {
+ for _, change := range w.ChangeLog.Items {
+ if change.Field == "resolution" {
+ if change.ToString == "" && change.FromString != "" {
+ verb = "reopened"
+ } else if change.ToString != "" && change.FromString == "" {
+ verb = "resolved"
+ }
+ break
+ }
+ }
+ }
+
+ tpl, err := template.New("post").Parse(tplBody)
+ if err != nil {
+ return "", err
+ }
+
+ var buf bytes.Buffer
+ if err := tpl.Execute(&buf, struct {
+ *Webhook
+ JIRAURL string
+ Verb string
+ }{
+ Webhook: w,
+ JIRAURL: jiraURL,
+ Verb: verb,
+ }); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/app/plugin/plugin.go b/app/plugin/plugin.go
new file mode 100644
index 000000000..b4fec1be8
--- /dev/null
+++ b/app/plugin/plugin.go
@@ -0,0 +1,9 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package plugin
+
+type Plugin interface {
+ Initialize(API)
+ Hooks
+}
diff --git a/app/plugins.go b/app/plugins.go
new file mode 100644
index 000000000..d569dcba2
--- /dev/null
+++ b/app/plugins.go
@@ -0,0 +1,86 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package app
+
+import (
+ "encoding/json"
+ "net/http"
+
+ l4g "github.com/alecthomas/log4go"
+
+ "github.com/gorilla/mux"
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
+
+ "github.com/mattermost/platform/app/plugin"
+ "github.com/mattermost/platform/app/plugin/jira"
+)
+
+type PluginAPI struct {
+ id string
+ router *mux.Router
+}
+
+func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error {
+ if b, err := json.Marshal(utils.Cfg.PluginSettings.Plugins[api.id]); err != nil {
+ return err
+ } else {
+ return json.Unmarshal(b, dest)
+ }
+}
+
+func (api *PluginAPI) PluginRouter() *mux.Router {
+ return api.router
+}
+
+func (api *PluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) {
+ return GetTeamByName(name)
+}
+
+func (api *PluginAPI) GetUserByName(name string) (*model.User, *model.AppError) {
+ return GetUserByUsername(name)
+}
+
+func (api *PluginAPI) GetChannelByName(teamId, name string) (*model.Channel, *model.AppError) {
+ return GetChannelByName(name, teamId)
+}
+
+func (api *PluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) {
+ return GetDirectChannel(userId1, userId2)
+}
+
+func (api *PluginAPI) CreatePost(post *model.Post, teamId string) (*model.Post, *model.AppError) {
+ return CreatePost(post, teamId, true)
+}
+
+func (api *PluginAPI) I18n(id string, r *http.Request) string {
+ if r != nil {
+ f, _ := utils.GetTranslationsAndLocale(nil, r)
+ return f(id)
+ }
+ f, _ := utils.GetTranslationsBySystemLocale()
+ return f(id)
+}
+
+func InitPlugins() {
+ plugins := map[string]plugin.Plugin{
+ "jira": &jira.Plugin{},
+ }
+ for id, p := range plugins {
+ l4g.Info("Initializing plugin: " + id)
+ api := &PluginAPI{
+ id: id,
+ router: Srv.Router.PathPrefix("/plugins/" + id).Subrouter(),
+ }
+ p.Initialize(api)
+ }
+ utils.AddConfigListener(func(before, after *model.Config) {
+ for _, p := range plugins {
+ p.OnConfigurationChange()
+ }
+ })
+ for _, p := range plugins {
+ p.OnConfigurationChange()
+ }
+}
diff --git a/app/webhook.go b/app/webhook.go
index e92805608..29f642ea8 100644
--- a/app/webhook.go
+++ b/app/webhook.go
@@ -490,48 +490,43 @@ func HandleIncomingWebhook(hookId string, req *model.IncomingWebhookRequest) *mo
var channel *model.Channel
var cchan store.StoreChannel
- var directUserId string
if len(channelName) != 0 {
if channelName[0] == '@' {
if result := <-Srv.Store.User().GetByUsername(channelName[1:]); result.Err != nil {
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", nil, "err="+result.Err.Message, http.StatusBadRequest)
} else {
- directUserId = result.Data.(*model.User).Id
- channelName = model.GetDMNameFromIds(directUserId, hook.UserId)
+ if ch, err := GetDirectChannel(hook.UserId, result.Data.(*model.User).Id); err != nil {
+ return err
+ } else {
+ channel = ch
+ }
}
} else if channelName[0] == '#' {
- channelName = channelName[1:]
+ cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName[1:], true)
+ } else {
+ cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName, true)
}
-
- cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName, true)
} else {
cchan = Srv.Store.Channel().Get(hook.ChannelId, true)
}
- overrideUsername := req.Username
- overrideIconUrl := req.IconURL
-
- result := <-cchan
- if result.Err != nil && result.Err.Id == store.MISSING_CHANNEL_ERROR && directUserId != "" {
- newChanResult := <-Srv.Store.Channel().CreateDirectChannel(directUserId, hook.UserId)
- if newChanResult.Err != nil {
- return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+newChanResult.Err.Message, http.StatusBadRequest)
+ if channel == nil {
+ result := <-cchan
+ if result.Err != nil {
+ return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode)
} else {
- channel = newChanResult.Data.(*model.Channel)
- InvalidateCacheForUser(directUserId)
- InvalidateCacheForUser(hook.UserId)
+ channel = result.Data.(*model.Channel)
}
- } else if result.Err != nil {
- return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode)
- } else {
- channel = result.Data.(*model.Channel)
}
if channel.Type != model.CHANNEL_OPEN && !HasPermissionToChannel(hook.UserId, channel.Id, model.PERMISSION_READ_CHANNEL) {
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", nil, "", http.StatusForbidden)
}
+ overrideUsername := req.Username
+ overrideIconUrl := req.IconURL
+
if _, err := CreateWebhookPost(hook.UserId, hook.TeamId, channel.Id, text, overrideUsername, overrideIconUrl, req.Props, webhookType); err != nil {
return err
}
diff --git a/cmd/platform/server.go b/cmd/platform/server.go
index 6186cbd86..8695129b7 100644
--- a/cmd/platform/server.go
+++ b/cmd/platform/server.go
@@ -73,6 +73,7 @@ func runServer(configFileLocation string) {
wsapi.InitRouter()
api4.InitApi(false)
api.InitApi()
+ app.InitPlugins()
wsapi.InitApi()
web.InitWeb()
diff --git a/config/config.json b/config/config.json
index ad4dc6120..6db6c772d 100644
--- a/config/config.json
+++ b/config/config.json
@@ -300,5 +300,8 @@
"JobSettings": {
"RunJobs": true,
"RunScheduler": true
+ },
+ "PluginSettings": {
+ "Plugins": {}
}
-}
+} \ No newline at end of file
diff --git a/model/config.go b/model/config.go
index 0755f514f..8c7cd0d68 100644
--- a/model/config.go
+++ b/model/config.go
@@ -449,6 +449,10 @@ type JobSettings struct {
RunScheduler *bool
}
+type PluginSettings struct {
+ Plugins map[string]interface{}
+}
+
type Config struct {
ServiceSettings ServiceSettings
TeamSettings TeamSettings
@@ -476,6 +480,7 @@ type Config struct {
ElasticsearchSettings ElasticsearchSettings
DataRetentionSettings DataRetentionSettings
JobSettings JobSettings
+ PluginSettings PluginSettings
}
func (o *Config) ToJson() string {
@@ -1427,6 +1432,10 @@ func (o *Config) SetDefaults() {
*o.JobSettings.RunScheduler = true
}
+ if o.PluginSettings.Plugins == nil {
+ o.PluginSettings.Plugins = make(map[string]interface{})
+ }
+
o.defaultWebrtcSettings()
}
diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx
index 9b27ab81e..f035424b6 100644
--- a/webapp/components/admin_console/admin_sidebar.jsx
+++ b/webapp/components/admin_console/admin_sidebar.jsx
@@ -522,6 +522,15 @@ export default class AdminSidebar extends React.Component {
/>
}
/>
+ <AdminSidebarSection
+ name='jira'
+ title={
+ <FormattedMessage
+ id='admin.sidebar.jira'
+ defaultMessage='JIRA (Beta)'
+ />
+ }
+ />
{webrtcSettings}
<AdminSidebarSection
name='external'
diff --git a/webapp/components/admin_console/generated_setting.jsx b/webapp/components/admin_console/generated_setting.jsx
index b6a495f93..2fed2f42f 100644
--- a/webapp/components/admin_console/generated_setting.jsx
+++ b/webapp/components/admin_console/generated_setting.jsx
@@ -20,7 +20,8 @@ export default class GeneratedSetting extends React.Component {
disabled: PropTypes.bool.isRequired,
disabledText: PropTypes.node,
helpText: PropTypes.node.isRequired,
- regenerateText: PropTypes.node
+ regenerateText: PropTypes.node,
+ regenerateHelpText: PropTypes.node
};
}
@@ -58,6 +59,15 @@ export default class GeneratedSetting extends React.Component {
);
}
+ let regenerateHelpText = null;
+ if (this.props.regenerateHelpText) {
+ regenerateHelpText = (
+ <div className='help-text'>
+ {this.props.regenerateHelpText}
+ </div>
+ );
+ }
+
return (
<div className='form-group'>
<label
@@ -88,6 +98,7 @@ export default class GeneratedSetting extends React.Component {
{this.props.regenerateText}
</button>
</div>
+ {regenerateHelpText}
</div>
</div>
);
diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx
index e9dc698aa..008fc2ffb 100644
--- a/webapp/components/suggestion/suggestion_box.jsx
+++ b/webapp/components/suggestion/suggestion_box.jsx
@@ -80,7 +80,12 @@ export default class SuggestionBox extends React.Component {
/**
* Function called when @mention is clicked
*/
- popoverMentionKeyClick: PropTypes.bool
+ popoverMentionKeyClick: PropTypes.bool,
+
+ /**
+ * The number of characters required to show the suggestion list, defaults to 1
+ */
+ requiredCharacters: PropTypes.number
}
static defaultProps = {
@@ -88,13 +93,15 @@ export default class SuggestionBox extends React.Component {
listStyle: 'top',
renderDividers: false,
completeOnTab: true,
- isRHS: false
+ isRHS: false,
+ requiredCharacters: 1
}
constructor(props) {
super(props);
this.handleBlur = this.handleBlur.bind(this);
+ this.handleFocus = this.handleFocus.bind(this);
this.handlePopoverMentionKeyClick = this.handlePopoverMentionKeyClick.bind(this);
this.handleCompleteWord = this.handleCompleteWord.bind(this);
@@ -163,11 +170,22 @@ export default class SuggestionBox extends React.Component {
}
}
+ handleFocus() {
+ setTimeout(() => {
+ const textbox = this.getTextbox();
+ const pretext = textbox.value.substring(0, textbox.selectionEnd);
+
+ if (pretext.length >= this.props.requiredCharacters) {
+ GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
+ }
+ });
+ }
+
handleChange(e) {
const textbox = this.getTextbox();
const pretext = textbox.value.substring(0, textbox.selectionEnd);
- if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext) {
+ if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext && pretext.length >= this.props.requiredCharacters) {
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
}
@@ -228,7 +246,8 @@ export default class SuggestionBox extends React.Component {
const suffix = text.substring(caret);
- this.refs.textbox.value = prefix + term + ' ' + suffix;
+ const newValue = prefix + term + ' ' + suffix;
+ this.refs.textbox.value = newValue;
if (this.props.onChange) {
// fake an input event to send back to parent components
@@ -242,9 +261,10 @@ export default class SuggestionBox extends React.Component {
if (this.props.onItemSelected) {
const items = SuggestionStore.getItems(this.suggestionId);
- for (const i of items) {
- if (i.name === term) {
- this.props.onItemSelected(i);
+ const terms = SuggestionStore.getTerms(this.suggestionId);
+ for (let i = 0; i < terms.length; i++) {
+ if (terms[i] === term) {
+ this.props.onItemSelected(items[i]);
break;
}
}
@@ -254,7 +274,9 @@ export default class SuggestionBox extends React.Component {
// set the caret position after the next rendering
window.requestAnimationFrame(() => {
- Utils.setCaretPosition(textbox, prefix.length + term.length + 1);
+ if (textbox.value === newValue) {
+ Utils.setCaretPosition(textbox, newValue.length);
+ }
});
for (const provider of this.props.providers) {
@@ -277,7 +299,9 @@ export default class SuggestionBox extends React.Component {
e.preventDefault();
} else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) {
this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId));
- this.props.onKeyDown(e);
+ if (this.props.onKeyDown) {
+ this.props.onKeyDown(e);
+ }
e.preventDefault();
} else if (e.which === KeyCodes.ESCAPE) {
GlobalActions.emitClearSuggestions(this.suggestionId);
@@ -321,11 +345,14 @@ export default class SuggestionBox extends React.Component {
Reflect.deleteProperty(props, 'completeOnTab');
Reflect.deleteProperty(props, 'isRHS');
Reflect.deleteProperty(props, 'popoverMentionKeyClick');
+ Reflect.deleteProperty(props, 'requiredCharacters');
const childProps = {
ref: 'textbox',
onBlur: this.handleBlur,
+ onFocus: this.handleFocus,
onInput: this.handleChange,
+ onChange() { /* this is only here to suppress warnings about onChange not being implemented for read-write inputs */ },
onCompositionStart: this.handleCompositionStart,
onCompositionUpdate: this.handleCompositionUpdate,
onCompositionEnd: this.handleCompositionEnd,
@@ -337,6 +364,7 @@ export default class SuggestionBox extends React.Component {
textbox = (
<input
type='text'
+ autoComplete='off'
{...props}
{...childProps}
/>
@@ -345,6 +373,7 @@ export default class SuggestionBox extends React.Component {
textbox = (
<input
type='search'
+ autoComplete='off'
{...props}
{...childProps}
/>
@@ -364,7 +393,7 @@ export default class SuggestionBox extends React.Component {
return (
<div ref='container'>
{textbox}
- {this.props.value &&
+ {this.props.value.length >= this.props.requiredCharacters &&
<SuggestionListComponent
suggestionId={this.suggestionId}
location={listStyle}
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 58674dd3c..77206c161 100755
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -481,6 +481,7 @@
"admin.integrations.custom": "Custom Integrations",
"admin.integrations.external": "External Services",
"admin.integrations.webrtc": "Mattermost WebRTC",
+ "admin.plugins.jira": "JIRA (Beta)",
"admin.ldap.baseDesc": "The Base DN is the Distinguished Name of the location where Mattermost should start its search for users in the AD/LDAP tree.",
"admin.ldap.baseEx": "E.g.: \"ou=Unit Name,dc=corp,dc=example,dc=com\"",
"admin.ldap.baseTitle": "BaseDN:",
@@ -626,6 +627,18 @@
"admin.password.requirementsDescription": "Character types required in a valid password.",
"admin.password.symbol": "At least one symbol (e.g. \"~!@#$%^&*()\")",
"admin.password.uppercase": "At least one uppercase letter",
+ "admin.plugins.jira.enabledLabel": "Enabled:",
+ "admin.plugins.jira.enabledDescription": "When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.",
+ "admin.plugins.jira.secretLabel": "Secret:",
+ "admin.plugins.jira.secretDescription": "This secret is used to authenticate to Mattermost.",
+ "admin.plugins.jira.secretRegenerateDescription": "Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.",
+ "admin.plugins.jira.userLabel": "User:",
+ "admin.plugins.jira.userDescription": "Select the username that this integration is attached to.",
+ "admin.plugins.jira.setupDescription": "Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.",
+ "admin.plugins.jira.webhookDocsLink": "documentation",
+ "admin.plugins.jira.secretParamPlaceholder": "secret",
+ "admin.plugins.jira.teamParamPlaceholder": "teamname",
+ "admin.plugins.jira.channelParamNamePlaceholder": "channelname",
"admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators.",
"admin.privacy.showEmailTitle": "Show Email Address: ",
"admin.privacy.showFullNameDescription": "When false, hides the full name of members from everyone except System Administrators. Username is shown in place of full name.",
@@ -832,6 +845,7 @@
"admin.sidebar.general": "General",
"admin.sidebar.gitlab": "GitLab",
"admin.sidebar.integrations": "Integrations",
+ "admin.sidebar.jira": "JIRA (Beta)",
"admin.sidebar.ldap": "AD/LDAP",
"admin.sidebar.legalAndSupport": "Legal and Support",
"admin.sidebar.license": "Edition and License",
diff --git a/webapp/plugins/jira/components/settings.jsx b/webapp/plugins/jira/components/settings.jsx
new file mode 100644
index 000000000..5e5b5fac6
--- /dev/null
+++ b/webapp/plugins/jira/components/settings.jsx
@@ -0,0 +1,251 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import crypto from 'crypto';
+
+import Suggestion from 'components/suggestion/suggestion.jsx';
+import Provider from 'components/suggestion/provider.jsx';
+import SuggestionBox from 'components/suggestion/suggestion_box.jsx';
+import SuggestionList from 'components/suggestion/suggestion_list.jsx';
+import {autocompleteUsersInTeam} from 'actions/user_actions.jsx';
+
+import AppDispatcher from 'dispatcher/app_dispatcher.jsx';
+import {Client4} from 'mattermost-redux/client';
+import {ActionTypes} from 'utils/constants.jsx';
+import * as Utils from 'utils/utils.jsx';
+
+import AdminSettings from 'components/admin_console/admin_settings.jsx';
+import {FormattedMessage} from 'react-intl';
+import SettingsGroup from 'components/admin_console/settings_group.jsx';
+import BooleanSetting from 'components/admin_console/boolean_setting.jsx';
+import GeneratedSetting from 'components/admin_console/generated_setting.jsx';
+import Setting from 'components/admin_console/setting.jsx';
+
+import './style.scss';
+
+class UserSuggestion extends Suggestion {
+ render() {
+ const {item, isSelection} = this.props;
+
+ let className = 'jirabots__name';
+ if (isSelection) {
+ className += ' suggestion--selected';
+ }
+
+ const username = item.username;
+ let description = '';
+
+ if ((item.first_name || item.last_name) && item.nickname) {
+ description = `- ${Utils.getFullName(item)} (${item.nickname})`;
+ } else if (item.nickname) {
+ description = `- (${item.nickname})`;
+ } else if (item.first_name || item.last_name) {
+ description = `- ${Utils.getFullName(item)}`;
+ }
+
+ return (
+ <div
+ className={className}
+ onClick={this.handleClick}
+ >
+ <div className='pull-left'>
+ <img
+ className='jirabot__image'
+ src={Client4.getUsersRoute() + '/' + item.id + '/image?_=' + (item.last_picture_update || 0)}
+ />
+ </div>
+ <div className='pull-left jirabot--align'>
+ <span>
+ {'@' + username}
+ </span>
+ <span className='jirabot__fullname'>
+ {' '}
+ {description}
+ </span>
+ </div>
+ </div>
+ );
+ }
+}
+
+class UserProvider extends Provider {
+ handlePretextChanged(suggestionId, pretext) {
+ const normalizedPretext = pretext.toLowerCase();
+ this.startNewRequest(suggestionId, normalizedPretext);
+
+ autocompleteUsersInTeam(
+ normalizedPretext,
+ (data) => {
+ if (this.shouldCancelDispatch(normalizedPretext)) {
+ return;
+ }
+
+ const users = Object.assign([], data.users);
+
+ AppDispatcher.handleServerAction({
+ type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS,
+ id: suggestionId,
+ matchedPretext: normalizedPretext,
+ terms: users.map((user) => user.username),
+ items: users,
+ component: UserSuggestion
+ });
+ }
+ );
+
+ return true;
+ }
+}
+
+export default class JIRASettings extends AdminSettings {
+ constructor(props) {
+ super(props);
+
+ this.getConfigFromState = this.getConfigFromState.bind(this);
+ this.renderSettings = this.renderSettings.bind(this);
+ this.handleSecretChange = this.handleSecretChange.bind(this);
+ this.handleEnabledChange = this.handleEnabledChange.bind(this);
+ this.handleUserSelected = this.handleUserSelected.bind(this);
+
+ this.userSuggestionProviders = [new UserProvider()];
+ }
+
+ getConfigFromState(config) {
+ config.PluginSettings.Plugins = {
+ jira: {
+ Enabled: this.state.enabled,
+ Secret: this.state.secret,
+ UserName: this.state.userName
+ }
+ };
+
+ return config;
+ }
+
+ getStateFromConfig(config) {
+ const settings = config.PluginSettings;
+
+ const ret = {
+ enabled: false,
+ secret: '',
+ userName: '',
+ siteURL: config.ServiceSettings.SiteURL
+ };
+
+ if (typeof settings.Plugins !== 'undefined' && typeof settings.Plugins.jira !== 'undefined') {
+ ret.enabled = settings.Plugins.jira.Enabled || settings.Plugins.jira.enabled || false;
+ ret.secret = settings.Plugins.jira.Secret || settings.Plugins.jira.secret || '';
+ ret.userName = settings.Plugins.jira.UserName || settings.Plugins.jira.username || '';
+ }
+
+ return ret;
+ }
+
+ handleSecretChange(id, secret) {
+ this.handleChange(id, secret.replace('+', '-').replace('/', '_'));
+ }
+
+ handleEnabledChange(enabled) {
+ if (enabled && this.state.secret === '') {
+ this.handleSecretChange('secret', crypto.randomBytes(256).toString('base64').substring(0, 32));
+ }
+ this.handleChange('enabled', enabled);
+ }
+
+ handleUserSelected(user) {
+ this.handleChange('userName', user.username);
+ }
+
+ renderTitle() {
+ return Utils.localizeMessage('admin.plugins.jira', 'JIRA (Beta)');
+ }
+
+ renderSettings() {
+ var webhookDocsLink = (
+ <a
+ href='https://about.mattermost.com/default-jira-plugin'
+ target='_blank'
+ rel='noopener noreferrer'
+ >
+ <FormattedMessage
+ id='admin.plugins.jira.webhookDocsLink'
+ defaultMessage='documentation'
+ />
+ </a>
+ );
+
+ return (
+ <SettingsGroup>
+ <BooleanSetting
+ id='enabled'
+ label={Utils.localizeMessage('admin.plugins.jira.enabledLabel', 'Enabled:')}
+ helpText={Utils.localizeMessage('admin.plugins.jira.enabledDescription', 'When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.')}
+ value={this.state.enabled}
+ onChange={(id, value) => this.handleEnabledChange(value)}
+ />
+ <Setting
+ label={Utils.localizeMessage('admin.plugins.jira.userLabel', 'User:')}
+ helpText={Utils.localizeMessage('admin.plugins.jira.userDescription', 'Select the username that this integration is attached to.')}
+ inputId='userName'
+ >
+ <div
+ className='jirabots__dropdown'
+ >
+ <SuggestionBox
+ id='userName'
+ className='form-control'
+ placeholder={Utils.localizeMessage('search_bar.search', 'Search')}
+ value={this.state.userName}
+ onChange={(e) => this.handleChange('userName', e.target.value)}
+ onItemSelected={this.handleUserSelected}
+ listComponent={SuggestionList}
+ listStyle='bottom'
+ providers={this.userSuggestionProviders}
+ disabled={!this.state.enabled}
+ type='input'
+ requiredCharacters={0}
+ />
+ </div>
+ </Setting>
+ <GeneratedSetting
+ id='secret'
+ label={Utils.localizeMessage('admin.plugins.jira.secretLabel', 'Secret:')}
+ helpText={Utils.localizeMessage('admin.plugins.jira.secretDescription', 'This secret is used to authenticate to Mattermost.')}
+ regenerateHelpText={Utils.localizeMessage('admin.plugins.jira.secretRegenerateDescription', 'Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.')}
+ value={this.state.secret}
+ onChange={this.handleSecretChange}
+ disabled={!this.state.enabled}
+ />
+ <div className='banner'>
+ <div className='banner__content'>
+ <p>
+ <FormattedMessage
+ id='admin.plugins.jira.setupDescription'
+ defaultMessage='Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.'
+ values={{
+ webhookDocsLink
+ }}
+ />
+ </p>
+ <p>
+ <code
+ dangerouslySetInnerHTML={{
+ __html: encodeURI(this.state.siteURL) +
+ '/plugins/jira/webhook?secret=' +
+ (this.state.secret ? encodeURIComponent(this.state.secret) : ('<b>' + Utils.localizeMessage('admin.plugins.jira.secretParamPlaceholder', 'secret') + '</b>')) +
+ '&team=<b>' +
+ Utils.localizeMessage('admin.plugins.jira.teamParamPlaceholder', 'teamname') +
+ '</b>&channel=<b>' +
+ Utils.localizeMessage('admin.plugins.jira.channelParamNamePlaceholder', 'channelname') +
+ '</b>'
+ }}
+ />
+ </p>
+ </div>
+ </div>
+ </SettingsGroup>
+ );
+ }
+}
diff --git a/webapp/plugins/jira/components/style.scss b/webapp/plugins/jira/components/style.scss
new file mode 100644
index 000000000..477328316
--- /dev/null
+++ b/webapp/plugins/jira/components/style.scss
@@ -0,0 +1,60 @@
+@charset 'UTF-8';
+
+@import 'compass/utilities';
+@import 'compass/css3';
+
+.jirabots__dropdown {
+ position: relative;
+}
+
+.jirabots__dropdown::before {
+ position: absolute;
+ top: 13px;
+ right: 8px;
+ content: " ";
+ pointer-events: none;
+
+ width: 0;
+ height: 0;
+
+ border-left: 8px solid transparent;
+ border-right: 8px solid transparent;
+ border-top: 8px solid #e2e2e2;
+}
+
+.jirabots__name {
+ @include clearfix;
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 20px;
+ margin: 0;
+ padding: 6px 10px;
+ position: relative;
+ white-space: nowrap;
+ width: 100%;
+ z-index: 101;
+}
+
+.jirabot__image {
+ @include border-radius(20px);
+ display: block;
+ font-size: 15px;
+ height: 16px;
+ line-height: 16px;
+ margin-right: 7px;
+ margin-top: 3px;
+ text-align: center;
+ width: 16px;
+
+ .jirabot--align {
+ display: inline-block;
+ max-width: 80%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.jirabot__fullname {
+ @include opacity(.5);
+}
diff --git a/webapp/routes/route_admin_console.jsx b/webapp/routes/route_admin_console.jsx
index 17e0290c2..15081a1d9 100644
--- a/webapp/routes/route_admin_console.jsx
+++ b/webapp/routes/route_admin_console.jsx
@@ -46,6 +46,7 @@ import LicenseSettings from 'components/admin_console/license_settings.jsx';
import Audits from 'components/admin_console/audits';
import Logs from 'components/admin_console/server_logs';
import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx';
+import JIRASettings from 'plugins/jira/components/settings.jsx';
export default (
<Route>
@@ -159,6 +160,10 @@ export default (
path='webrtc'
component={WebrtcSettings}
/>
+ <Route
+ path='jira'
+ component={JIRASettings}
+ />
</Route>
<Route path='files'>
<IndexRedirect to='storage'/>
diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss
index 2d4ca6be1..829ed107e 100644
--- a/webapp/sass/routes/_admin-console.scss
+++ b/webapp/sass/routes/_admin-console.scss
@@ -305,6 +305,10 @@
.status-icon-error {
color: #ea6262;
}
+
+ .suggestion--selected {
+ background-color: #e2e2e2;
+ }
}
.brand-img {