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
Этот коммит содержится в:
2
Makefile
2
Makefile
@@ -539,6 +539,8 @@ govet:
|
|||||||
$(GO) vet $(GOFLAGS) ./api || exit 1
|
$(GO) vet $(GOFLAGS) ./api || exit 1
|
||||||
$(GO) vet $(GOFLAGS) ./api4 || exit 1
|
$(GO) vet $(GOFLAGS) ./api4 || exit 1
|
||||||
$(GO) vet $(GOFLAGS) ./app || 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) ./cmd/platform || exit 1
|
||||||
$(GO) vet $(GOFLAGS) ./einterfaces || exit 1
|
$(GO) vet $(GOFLAGS) ./einterfaces || exit 1
|
||||||
$(GO) vet $(GOFLAGS) ./jobs || exit 1
|
$(GO) vet $(GOFLAGS) ./jobs || exit 1
|
||||||
|
|||||||
@@ -1206,3 +1206,19 @@ func GetPinnedPosts(channelId string) (*model.PostList, *model.AppError) {
|
|||||||
return result.Data.(*model.PostList), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -38,6 +39,7 @@ const (
|
|||||||
TRACK_CONFIG_ANALYTICS = "config_analytics"
|
TRACK_CONFIG_ANALYTICS = "config_analytics"
|
||||||
TRACK_CONFIG_ANNOUNCEMENT = "config_announcement"
|
TRACK_CONFIG_ANNOUNCEMENT = "config_announcement"
|
||||||
TRACK_CONFIG_ELASTICSEARCH = "config_elasticsearch"
|
TRACK_CONFIG_ELASTICSEARCH = "config_elasticsearch"
|
||||||
|
TRACK_CONFIG_PLUGIN = "config_plugin"
|
||||||
|
|
||||||
TRACK_ACTIVITY = "activity"
|
TRACK_ACTIVITY = "activity"
|
||||||
TRACK_LICENSE = "license"
|
TRACK_LICENSE = "license"
|
||||||
@@ -87,6 +89,23 @@ func isDefault(setting interface{}, defaultValue interface{}) bool {
|
|||||||
return false
|
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() {
|
func trackActivity() {
|
||||||
var userCount int64
|
var userCount int64
|
||||||
var activeUserCount int64
|
var activeUserCount int64
|
||||||
@@ -393,6 +412,10 @@ func trackConfig() {
|
|||||||
"enable_searching": *utils.Cfg.ElasticsearchSettings.EnableSearching,
|
"enable_searching": *utils.Cfg.ElasticsearchSettings.EnableSearching,
|
||||||
"sniff": *utils.Cfg.ElasticsearchSettings.Sniff,
|
"sniff": *utils.Cfg.ElasticsearchSettings.Sniff,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
SendDiagnostic(TRACK_CONFIG_PLUGIN, map[string]interface{}{
|
||||||
|
"enable_jira": pluginSetting("jira", "enabled", false),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackLicense() {
|
func trackLicense() {
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ func newTestServer() (chan string, *httptest.Server) {
|
|||||||
return result, 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) {
|
func TestDiagnostics(t *testing.T) {
|
||||||
Setup().InitBasic()
|
Setup().InitBasic()
|
||||||
|
|
||||||
@@ -116,6 +134,7 @@ func TestDiagnostics(t *testing.T) {
|
|||||||
TRACK_CONFIG_SUPPORT,
|
TRACK_CONFIG_SUPPORT,
|
||||||
TRACK_CONFIG_NATIVEAPP,
|
TRACK_CONFIG_NATIVEAPP,
|
||||||
TRACK_CONFIG_ANALYTICS,
|
TRACK_CONFIG_ANALYTICS,
|
||||||
|
TRACK_CONFIG_PLUGIN,
|
||||||
TRACK_ACTIVITY,
|
TRACK_ACTIVITY,
|
||||||
TRACK_SERVER,
|
TRACK_SERVER,
|
||||||
} {
|
} {
|
||||||
|
|||||||
37
app/plugin/api.go
Обычный файл
37
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
|
||||||
|
}
|
||||||
9
app/plugin/base.go
Обычный файл
9
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() {}
|
||||||
10
app/plugin/hooks.go
Обычный файл
10
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()
|
||||||
|
}
|
||||||
10
app/plugin/jira/configuration.go
Обычный файл
10
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
|
||||||
|
}
|
||||||
78
app/plugin/jira/plugin.go
Обычный файл
78
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/plugin/jira/webhook.go
Обычный файл
168
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
|
||||||
|
}
|
||||||
9
app/plugin/plugin.go
Обычный файл
9
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
|
||||||
|
}
|
||||||
86
app/plugins.go
Обычный файл
86
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,48 +490,43 @@ func HandleIncomingWebhook(hookId string, req *model.IncomingWebhookRequest) *mo
|
|||||||
|
|
||||||
var channel *model.Channel
|
var channel *model.Channel
|
||||||
var cchan store.StoreChannel
|
var cchan store.StoreChannel
|
||||||
var directUserId string
|
|
||||||
|
|
||||||
if len(channelName) != 0 {
|
if len(channelName) != 0 {
|
||||||
if channelName[0] == '@' {
|
if channelName[0] == '@' {
|
||||||
if result := <-Srv.Store.User().GetByUsername(channelName[1:]); result.Err != nil {
|
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)
|
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", nil, "err="+result.Err.Message, http.StatusBadRequest)
|
||||||
} else {
|
} else {
|
||||||
directUserId = result.Data.(*model.User).Id
|
if ch, err := GetDirectChannel(hook.UserId, result.Data.(*model.User).Id); err != nil {
|
||||||
channelName = model.GetDMNameFromIds(directUserId, hook.UserId)
|
return err
|
||||||
|
} else {
|
||||||
|
channel = ch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if channelName[0] == '#' {
|
} 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 {
|
} else {
|
||||||
cchan = Srv.Store.Channel().Get(hook.ChannelId, true)
|
cchan = Srv.Store.Channel().Get(hook.ChannelId, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
overrideUsername := req.Username
|
if channel == nil {
|
||||||
overrideIconUrl := req.IconURL
|
result := <-cchan
|
||||||
|
if result.Err != nil {
|
||||||
result := <-cchan
|
return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode)
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
channel = newChanResult.Data.(*model.Channel)
|
channel = result.Data.(*model.Channel)
|
||||||
InvalidateCacheForUser(directUserId)
|
|
||||||
InvalidateCacheForUser(hook.UserId)
|
|
||||||
}
|
}
|
||||||
} 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) {
|
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)
|
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 {
|
if _, err := CreateWebhookPost(hook.UserId, hook.TeamId, channel.Id, text, overrideUsername, overrideIconUrl, req.Props, webhookType); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func runServer(configFileLocation string) {
|
|||||||
wsapi.InitRouter()
|
wsapi.InitRouter()
|
||||||
api4.InitApi(false)
|
api4.InitApi(false)
|
||||||
api.InitApi()
|
api.InitApi()
|
||||||
|
app.InitPlugins()
|
||||||
wsapi.InitApi()
|
wsapi.InitApi()
|
||||||
web.InitWeb()
|
web.InitWeb()
|
||||||
|
|
||||||
|
|||||||
@@ -300,5 +300,8 @@
|
|||||||
"JobSettings": {
|
"JobSettings": {
|
||||||
"RunJobs": true,
|
"RunJobs": true,
|
||||||
"RunScheduler": true
|
"RunScheduler": true
|
||||||
|
},
|
||||||
|
"PluginSettings": {
|
||||||
|
"Plugins": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,6 +449,10 @@ type JobSettings struct {
|
|||||||
RunScheduler *bool
|
RunScheduler *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginSettings struct {
|
||||||
|
Plugins map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceSettings ServiceSettings
|
ServiceSettings ServiceSettings
|
||||||
TeamSettings TeamSettings
|
TeamSettings TeamSettings
|
||||||
@@ -476,6 +480,7 @@ type Config struct {
|
|||||||
ElasticsearchSettings ElasticsearchSettings
|
ElasticsearchSettings ElasticsearchSettings
|
||||||
DataRetentionSettings DataRetentionSettings
|
DataRetentionSettings DataRetentionSettings
|
||||||
JobSettings JobSettings
|
JobSettings JobSettings
|
||||||
|
PluginSettings PluginSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Config) ToJson() string {
|
func (o *Config) ToJson() string {
|
||||||
@@ -1427,6 +1432,10 @@ func (o *Config) SetDefaults() {
|
|||||||
*o.JobSettings.RunScheduler = true
|
*o.JobSettings.RunScheduler = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o.PluginSettings.Plugins == nil {
|
||||||
|
o.PluginSettings.Plugins = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
o.defaultWebrtcSettings()
|
o.defaultWebrtcSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -522,6 +522,15 @@ export default class AdminSidebar extends React.Component {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<AdminSidebarSection
|
||||||
|
name='jira'
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='admin.sidebar.jira'
|
||||||
|
defaultMessage='JIRA (Beta)'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{webrtcSettings}
|
{webrtcSettings}
|
||||||
<AdminSidebarSection
|
<AdminSidebarSection
|
||||||
name='external'
|
name='external'
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export default class GeneratedSetting extends React.Component {
|
|||||||
disabled: PropTypes.bool.isRequired,
|
disabled: PropTypes.bool.isRequired,
|
||||||
disabledText: PropTypes.node,
|
disabledText: PropTypes.node,
|
||||||
helpText: PropTypes.node.isRequired,
|
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 (
|
return (
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
<label
|
<label
|
||||||
@@ -88,6 +98,7 @@ export default class GeneratedSetting extends React.Component {
|
|||||||
{this.props.regenerateText}
|
{this.props.regenerateText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{regenerateHelpText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ export default class SuggestionBox extends React.Component {
|
|||||||
/**
|
/**
|
||||||
* Function called when @mention is clicked
|
* 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 = {
|
static defaultProps = {
|
||||||
@@ -88,13 +93,15 @@ export default class SuggestionBox extends React.Component {
|
|||||||
listStyle: 'top',
|
listStyle: 'top',
|
||||||
renderDividers: false,
|
renderDividers: false,
|
||||||
completeOnTab: true,
|
completeOnTab: true,
|
||||||
isRHS: false
|
isRHS: false,
|
||||||
|
requiredCharacters: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
this.handleBlur = this.handleBlur.bind(this);
|
||||||
|
this.handleFocus = this.handleFocus.bind(this);
|
||||||
|
|
||||||
this.handlePopoverMentionKeyClick = this.handlePopoverMentionKeyClick.bind(this);
|
this.handlePopoverMentionKeyClick = this.handlePopoverMentionKeyClick.bind(this);
|
||||||
this.handleCompleteWord = this.handleCompleteWord.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) {
|
handleChange(e) {
|
||||||
const textbox = this.getTextbox();
|
const textbox = this.getTextbox();
|
||||||
const pretext = textbox.value.substring(0, textbox.selectionEnd);
|
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);
|
GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +246,8 @@ export default class SuggestionBox extends React.Component {
|
|||||||
|
|
||||||
const suffix = text.substring(caret);
|
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) {
|
if (this.props.onChange) {
|
||||||
// fake an input event to send back to parent components
|
// 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) {
|
if (this.props.onItemSelected) {
|
||||||
const items = SuggestionStore.getItems(this.suggestionId);
|
const items = SuggestionStore.getItems(this.suggestionId);
|
||||||
for (const i of items) {
|
const terms = SuggestionStore.getTerms(this.suggestionId);
|
||||||
if (i.name === term) {
|
for (let i = 0; i < terms.length; i++) {
|
||||||
this.props.onItemSelected(i);
|
if (terms[i] === term) {
|
||||||
|
this.props.onItemSelected(items[i]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,7 +274,9 @@ export default class SuggestionBox extends React.Component {
|
|||||||
|
|
||||||
// set the caret position after the next rendering
|
// set the caret position after the next rendering
|
||||||
window.requestAnimationFrame(() => {
|
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) {
|
for (const provider of this.props.providers) {
|
||||||
@@ -277,7 +299,9 @@ export default class SuggestionBox extends React.Component {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) {
|
} else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) {
|
||||||
this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId));
|
this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId));
|
||||||
this.props.onKeyDown(e);
|
if (this.props.onKeyDown) {
|
||||||
|
this.props.onKeyDown(e);
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.which === KeyCodes.ESCAPE) {
|
} else if (e.which === KeyCodes.ESCAPE) {
|
||||||
GlobalActions.emitClearSuggestions(this.suggestionId);
|
GlobalActions.emitClearSuggestions(this.suggestionId);
|
||||||
@@ -321,11 +345,14 @@ export default class SuggestionBox extends React.Component {
|
|||||||
Reflect.deleteProperty(props, 'completeOnTab');
|
Reflect.deleteProperty(props, 'completeOnTab');
|
||||||
Reflect.deleteProperty(props, 'isRHS');
|
Reflect.deleteProperty(props, 'isRHS');
|
||||||
Reflect.deleteProperty(props, 'popoverMentionKeyClick');
|
Reflect.deleteProperty(props, 'popoverMentionKeyClick');
|
||||||
|
Reflect.deleteProperty(props, 'requiredCharacters');
|
||||||
|
|
||||||
const childProps = {
|
const childProps = {
|
||||||
ref: 'textbox',
|
ref: 'textbox',
|
||||||
onBlur: this.handleBlur,
|
onBlur: this.handleBlur,
|
||||||
|
onFocus: this.handleFocus,
|
||||||
onInput: this.handleChange,
|
onInput: this.handleChange,
|
||||||
|
onChange() { /* this is only here to suppress warnings about onChange not being implemented for read-write inputs */ },
|
||||||
onCompositionStart: this.handleCompositionStart,
|
onCompositionStart: this.handleCompositionStart,
|
||||||
onCompositionUpdate: this.handleCompositionUpdate,
|
onCompositionUpdate: this.handleCompositionUpdate,
|
||||||
onCompositionEnd: this.handleCompositionEnd,
|
onCompositionEnd: this.handleCompositionEnd,
|
||||||
@@ -337,6 +364,7 @@ export default class SuggestionBox extends React.Component {
|
|||||||
textbox = (
|
textbox = (
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
|
autoComplete='off'
|
||||||
{...props}
|
{...props}
|
||||||
{...childProps}
|
{...childProps}
|
||||||
/>
|
/>
|
||||||
@@ -345,6 +373,7 @@ export default class SuggestionBox extends React.Component {
|
|||||||
textbox = (
|
textbox = (
|
||||||
<input
|
<input
|
||||||
type='search'
|
type='search'
|
||||||
|
autoComplete='off'
|
||||||
{...props}
|
{...props}
|
||||||
{...childProps}
|
{...childProps}
|
||||||
/>
|
/>
|
||||||
@@ -364,7 +393,7 @@ export default class SuggestionBox extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<div ref='container'>
|
<div ref='container'>
|
||||||
{textbox}
|
{textbox}
|
||||||
{this.props.value &&
|
{this.props.value.length >= this.props.requiredCharacters &&
|
||||||
<SuggestionListComponent
|
<SuggestionListComponent
|
||||||
suggestionId={this.suggestionId}
|
suggestionId={this.suggestionId}
|
||||||
location={listStyle}
|
location={listStyle}
|
||||||
|
|||||||
@@ -481,6 +481,7 @@
|
|||||||
"admin.integrations.custom": "Custom Integrations",
|
"admin.integrations.custom": "Custom Integrations",
|
||||||
"admin.integrations.external": "External Services",
|
"admin.integrations.external": "External Services",
|
||||||
"admin.integrations.webrtc": "Mattermost WebRTC",
|
"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.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.baseEx": "E.g.: \"ou=Unit Name,dc=corp,dc=example,dc=com\"",
|
||||||
"admin.ldap.baseTitle": "BaseDN:",
|
"admin.ldap.baseTitle": "BaseDN:",
|
||||||
@@ -626,6 +627,18 @@
|
|||||||
"admin.password.requirementsDescription": "Character types required in a valid password.",
|
"admin.password.requirementsDescription": "Character types required in a valid password.",
|
||||||
"admin.password.symbol": "At least one symbol (e.g. \"~!@#$%^&*()\")",
|
"admin.password.symbol": "At least one symbol (e.g. \"~!@#$%^&*()\")",
|
||||||
"admin.password.uppercase": "At least one uppercase letter",
|
"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.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators.",
|
||||||
"admin.privacy.showEmailTitle": "Show Email Address: ",
|
"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.",
|
"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.general": "General",
|
||||||
"admin.sidebar.gitlab": "GitLab",
|
"admin.sidebar.gitlab": "GitLab",
|
||||||
"admin.sidebar.integrations": "Integrations",
|
"admin.sidebar.integrations": "Integrations",
|
||||||
|
"admin.sidebar.jira": "JIRA (Beta)",
|
||||||
"admin.sidebar.ldap": "AD/LDAP",
|
"admin.sidebar.ldap": "AD/LDAP",
|
||||||
"admin.sidebar.legalAndSupport": "Legal and Support",
|
"admin.sidebar.legalAndSupport": "Legal and Support",
|
||||||
"admin.sidebar.license": "Edition and License",
|
"admin.sidebar.license": "Edition and License",
|
||||||
|
|||||||
251
webapp/plugins/jira/components/settings.jsx
Обычный файл
251
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
webapp/plugins/jira/components/style.scss
Обычный файл
60
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);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import LicenseSettings from 'components/admin_console/license_settings.jsx';
|
|||||||
import Audits from 'components/admin_console/audits';
|
import Audits from 'components/admin_console/audits';
|
||||||
import Logs from 'components/admin_console/server_logs';
|
import Logs from 'components/admin_console/server_logs';
|
||||||
import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx';
|
import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx';
|
||||||
|
import JIRASettings from 'plugins/jira/components/settings.jsx';
|
||||||
|
|
||||||
export default (
|
export default (
|
||||||
<Route>
|
<Route>
|
||||||
@@ -159,6 +160,10 @@ export default (
|
|||||||
path='webrtc'
|
path='webrtc'
|
||||||
component={WebrtcSettings}
|
component={WebrtcSettings}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='jira'
|
||||||
|
component={JIRASettings}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='files'>
|
<Route path='files'>
|
||||||
<IndexRedirect to='storage'/>
|
<IndexRedirect to='storage'/>
|
||||||
|
|||||||
@@ -305,6 +305,10 @@
|
|||||||
.status-icon-error {
|
.status-icon-error {
|
||||||
color: #ea6262;
|
color: #ea6262;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestion--selected {
|
||||||
|
background-color: #e2e2e2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-img {
|
.brand-img {
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user