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
Этот коммит содержится в:
Chris
2017-08-02 01:36:54 -07:00
коммит произвёл GitHub
родитель c6bf235ec2
Коммит 65817e13c7
24 изменённых файлов: 891 добавлений и 33 удалений

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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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
}