From 65817e13c7900ea81947e40e177459cfea8acee4 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 2 Aug 2017 01:36:54 -0700 Subject: 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 --- app/plugin/api.go | 37 +++++++++ app/plugin/base.go | 9 +++ app/plugin/hooks.go | 10 +++ app/plugin/jira/configuration.go | 10 +++ app/plugin/jira/plugin.go | 78 ++++++++++++++++++ app/plugin/jira/webhook.go | 168 +++++++++++++++++++++++++++++++++++++++ app/plugin/plugin.go | 9 +++ 7 files changed, 321 insertions(+) create mode 100644 app/plugin/api.go create mode 100644 app/plugin/base.go create mode 100644 app/plugin/hooks.go create mode 100644 app/plugin/jira/configuration.go create mode 100644 app/plugin/jira/plugin.go create mode 100644 app/plugin/jira/webhook.go create mode 100644 app/plugin/plugin.go (limited to 'app/plugin') 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 +} -- cgit v1.2.3-1-g7c22