summaryrefslogtreecommitdiffstats
path: root/app/plugin
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 /app/plugin
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
Diffstat (limited to 'app/plugin')
-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
7 files changed, 321 insertions, 0 deletions
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
+}