add new commands
This commit is contained in:
parent
ced59c39d2
commit
1a056be4c6
@ -12,6 +12,7 @@ import (
|
|||||||
func (p *Plugin) InitApi() {
|
func (p *Plugin) InitApi() {
|
||||||
p.router = mux.NewRouter()
|
p.router = mux.NewRouter()
|
||||||
p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST")
|
p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST")
|
||||||
|
p.router.HandleFunc("/dialog/submit", p.handleDialogSubmit).Methods("POST")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
@ -52,7 +53,12 @@ func (p *Plugin) ExecuteCommand(
|
|||||||
|
|
||||||
switch split[1] {
|
switch split[1] {
|
||||||
case "setup":
|
case "setup":
|
||||||
return p.commandSetup(args)
|
if len(split) == 2 {
|
||||||
|
return p.commandSetup(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.commandSetupHook(args, split[2])
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
return p.commandLink(args, split)
|
return p.commandLink(args, split)
|
||||||
case "unlink":
|
case "unlink":
|
||||||
@ -68,16 +74,18 @@ func (p *Plugin) ExecuteCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
||||||
|
// root: /sentry
|
||||||
sentry := model.NewAutocompleteData(
|
sentry := model.NewAutocompleteData(
|
||||||
"sentry",
|
"sentry",
|
||||||
"",
|
"",
|
||||||
"Sentry integration commands",
|
"Sentry integration commands",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// /sentry setup [hook]
|
||||||
setup := model.NewAutocompleteData(
|
setup := model.NewAutocompleteData(
|
||||||
"setup",
|
"setup",
|
||||||
"",
|
"[hook]",
|
||||||
"Initial setup of Sentry integration",
|
"Initial setup or configure a specific webhook",
|
||||||
)
|
)
|
||||||
|
|
||||||
help := model.NewAutocompleteData(
|
help := model.NewAutocompleteData(
|
||||||
@ -94,21 +102,39 @@ func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
|||||||
|
|
||||||
link := model.NewAutocompleteData(
|
link := model.NewAutocompleteData(
|
||||||
"link",
|
"link",
|
||||||
"[project]",
|
"[project_slug]",
|
||||||
"Link a Sentry project to this channel",
|
"Link a Sentry project to this channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
unlink := model.NewAutocompleteData(
|
unlink := model.NewAutocompleteData(
|
||||||
"unlink",
|
"unlink",
|
||||||
"[project]",
|
"[project_slug]",
|
||||||
"Unlink a Sentry project from this channel",
|
"Unlink a Sentry project from this channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// register top-level commands
|
||||||
sentry.AddCommand(setup)
|
sentry.AddCommand(setup)
|
||||||
sentry.AddCommand(help)
|
|
||||||
sentry.AddCommand(list)
|
|
||||||
sentry.AddCommand(link)
|
sentry.AddCommand(link)
|
||||||
sentry.AddCommand(unlink)
|
sentry.AddCommand(unlink)
|
||||||
|
sentry.AddCommand(list)
|
||||||
|
sentry.AddCommand(help)
|
||||||
|
|
||||||
|
// /sentry setup <hook>
|
||||||
|
hooks := []struct {
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"event_alert", "Configure event alert webhook"},
|
||||||
|
{"metric_alert", "Configure metric alert webhook"},
|
||||||
|
{"issue", "Configure issue webhook"},
|
||||||
|
{"comment", "Configure comment webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range hooks {
|
||||||
|
setup.AddCommand(
|
||||||
|
model.NewAutocompleteData(h.name, "", h.desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return sentry
|
return sentry
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,21 +12,6 @@ import (
|
|||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
var availableFields = []*model.PostActionOptions{
|
|
||||||
{Text: "Project", Value: "project"},
|
|
||||||
{Text: "Project ID", Value: "project_id"},
|
|
||||||
{Text: "Issue ID", Value: "issue_id"},
|
|
||||||
{Text: "Environment", Value: "environment"},
|
|
||||||
{Text: "Level", Value: "level"},
|
|
||||||
{Text: "Culprit", Value: "culprit"},
|
|
||||||
{Text: "Logger", Value: "logger"},
|
|
||||||
{Text: "Platform", Value: "platform"},
|
|
||||||
{Text: "Release", Value: "release"},
|
|
||||||
{Text: "User", Value: "user"},
|
|
||||||
{Text: "Exception", Value: "exception"},
|
|
||||||
{Text: "Tags", Value: "tags"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Plugin) fetchSentryProject(projectSlug string) (*LinkedProject, error) {
|
func (p *Plugin) fetchSentryProject(projectSlug string) (*LinkedProject, error) {
|
||||||
cfg := p.GetConfiguration()
|
cfg := p.GetConfiguration()
|
||||||
|
|
||||||
@ -92,8 +77,14 @@ func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.Co
|
|||||||
|
|
||||||
projectSlug := split[2]
|
projectSlug := split[2]
|
||||||
channelID := args.ChannelId
|
channelID := args.ChannelId
|
||||||
|
hooks := HookSettings{
|
||||||
|
EventAlert: true,
|
||||||
|
MetricAlert: true,
|
||||||
|
Issue: true,
|
||||||
|
Comment: true,
|
||||||
|
}
|
||||||
|
|
||||||
project, err := p.fetchSentryProject(projectSlug)
|
project, err := p.linkProjectToChannel(projectSlug, channelID, hooks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
@ -101,38 +92,6 @@ func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.Co
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
project.ChannelID = channelID
|
|
||||||
|
|
||||||
// сохраняем проект
|
|
||||||
bytes, _ := json.Marshal(project)
|
|
||||||
if err := p.API.KVSet("ru.loop.plugin.sentry:project:"+project.ID, bytes); err != nil {
|
|
||||||
return &model.CommandResponse{
|
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
|
||||||
Text: "❌ Failed to save project",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// обновляем список IDs
|
|
||||||
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
|
||||||
var projects []string
|
|
||||||
if data != nil {
|
|
||||||
_ = json.Unmarshal(data, &projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists := false
|
|
||||||
for _, id := range projects {
|
|
||||||
if id == project.ID {
|
|
||||||
exists = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
projects = append(projects, project.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := json.Marshal(projects)
|
|
||||||
_ = p.API.KVSet("ru.loop.plugin.sentry:projects", updated)
|
|
||||||
|
|
||||||
return &model.CommandResponse{
|
return &model.CommandResponse{
|
||||||
ResponseType: model.CommandResponseTypeEphemeral,
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
Text: fmt.Sprintf(
|
Text: fmt.Sprintf(
|
||||||
@ -278,7 +237,14 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Получаем каналы пользователя
|
// Получаем каналы пользователя
|
||||||
channels, _ := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false)
|
channels, appErr := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false)
|
||||||
|
if appErr != nil {
|
||||||
|
return &model.CommandResponse{
|
||||||
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
|
Text: "❌ Failed to load channels",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
options := []*model.PostActionOptions{}
|
options := []*model.PostActionOptions{}
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
options = append(options, &model.PostActionOptions{
|
options = append(options, &model.PostActionOptions{
|
||||||
@ -287,41 +253,31 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Поля webhook (для CSV ввода)
|
|
||||||
fieldList := "project,project_id,issue_id,environment,level,culprit,logger,platform,release,user,exception,tags"
|
|
||||||
|
|
||||||
modal := &model.Dialog{
|
modal := &model.Dialog{
|
||||||
Title: "Sentry Setup",
|
Title: "Sentry Setup",
|
||||||
CallbackId: "sentry_setup",
|
CallbackId: "sentry_setup",
|
||||||
SubmitLabel: "Save",
|
SubmitLabel: "Save",
|
||||||
Elements: []model.DialogElement{
|
Elements: []model.DialogElement{
|
||||||
|
// ───── General ─────
|
||||||
{
|
{
|
||||||
DisplayName: "Channel",
|
DisplayName: "Default channel",
|
||||||
Name: "channel_id",
|
Name: "default_channel_id",
|
||||||
Type: "select",
|
Type: "select",
|
||||||
Options: options,
|
Options: options,
|
||||||
Default: args.ChannelId,
|
Default: args.ChannelId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
DisplayName: "Sentry Project Slug",
|
DisplayName: "Sentry project slug",
|
||||||
Name: "project_slug",
|
Name: "project_slug",
|
||||||
Type: "text",
|
Type: "text",
|
||||||
Placeholder: "Enter project slug",
|
Placeholder: "frontend-app",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
DisplayName: "Webhook fields",
|
// ───── Webhook types ─────
|
||||||
Name: "fields",
|
{DisplayName: "Event alerts", Name: "hook_event_alert", Type: "bool", Default: "true"},
|
||||||
Type: "text",
|
{DisplayName: "Metric alerts", Name: "hook_metric_alert", Type: "bool", Default: "true"},
|
||||||
Default: fieldList,
|
{DisplayName: "Issues", Name: "hook_issue", Type: "bool", Default: "true"},
|
||||||
Placeholder: "Comma-separated list of fields to show in webhook",
|
{DisplayName: "Comments", Name: "hook_comment", Type: "bool", Default: "true"},
|
||||||
HelpText: "Example: project,issue_id,environment,level,exception",
|
|
||||||
},
|
|
||||||
{DisplayName: "Fatal color", Name: "color_fatal", Type: "text", Default: "#B10DC9"},
|
|
||||||
{DisplayName: "Error color", Name: "color_error", Type: "text", Default: "#FF4136"},
|
|
||||||
{DisplayName: "Warning color", Name: "color_warning", Type: "text", Default: "#FF851B"},
|
|
||||||
{DisplayName: "Info color", Name: "color_info", Type: "text", Default: "#0074D9"},
|
|
||||||
{DisplayName: "Debug color", Name: "color_debug", Type: "text", Default: "#2ECC40"},
|
|
||||||
{DisplayName: "Log color", Name: "color_log", Type: "text", Default: "#AAAAAA"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,3 +296,74 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
|
|||||||
|
|
||||||
return &model.CommandResponse{}, nil
|
return &model.CommandResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) linkProjectToChannel(
|
||||||
|
projectSlug string,
|
||||||
|
channelID string,
|
||||||
|
hooks HookSettings,
|
||||||
|
) (*LinkedProject, error) {
|
||||||
|
project, err := p.fetchSentryProject(projectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
project.ChannelID = channelID
|
||||||
|
project.Hooks = hooks
|
||||||
|
|
||||||
|
bytes, _ := json.Marshal(project)
|
||||||
|
if err := p.API.KVSet("ru.loop.plugin.sentry:project:"+project.ID, bytes); err != nil {
|
||||||
|
return nil, errors.New("failed to save project")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
||||||
|
var projects []string
|
||||||
|
if data != nil {
|
||||||
|
_ = json.Unmarshal(data, &projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists := false
|
||||||
|
for _, id := range projects {
|
||||||
|
if id == project.ID {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
projects = append(projects, project.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := json.Marshal(projects)
|
||||||
|
_ = p.API.KVSet("ru.loop.plugin.sentry:projects", updated)
|
||||||
|
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) commandSetupHook(
|
||||||
|
args *model.CommandArgs,
|
||||||
|
hook string,
|
||||||
|
) (*model.CommandResponse, *model.AppError) {
|
||||||
|
|
||||||
|
project, err := p.getProjectByChannel(args.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
return ephemeral("❌ " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hookType := HookType(hook)
|
||||||
|
|
||||||
|
if !isHookEnabled(project, hookType) {
|
||||||
|
return ephemeral("⚠️ This webhook is disabled in setup"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch hookType {
|
||||||
|
case HookEventAlert:
|
||||||
|
return p.openEventAlertSetup(args, project)
|
||||||
|
case HookMetricAlert:
|
||||||
|
return p.openMetricAlertSetup(args, project)
|
||||||
|
case HookIssue:
|
||||||
|
return p.openIssueSetup(args, project)
|
||||||
|
case HookComment:
|
||||||
|
return p.openCommentSetup(args, project)
|
||||||
|
default:
|
||||||
|
return ephemeral("❌ Unknown webhook type"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
104
server/plugin/modal.go
Normal file
104
server/plugin/modal.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Plugin) handleDialogSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.SubmitDialogRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.CallbackId != "sentry_setup" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := req.Submission
|
||||||
|
|
||||||
|
projectSlug := sub["project_slug"].(string)
|
||||||
|
channelID := sub["default_channel_id"].(string)
|
||||||
|
|
||||||
|
hooks := HookSettings{
|
||||||
|
EventAlert: getBool(sub, "hook_event_alert", true),
|
||||||
|
MetricAlert: getBool(sub, "hook_metric_alert", true),
|
||||||
|
Issue: getBool(sub, "hook_issue", true),
|
||||||
|
Comment: getBool(sub, "hook_comment", true),
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := p.linkProjectToChannel(projectSlug, channelID, hooks)
|
||||||
|
if err != nil {
|
||||||
|
p.API.SendEphemeralPost(req.UserId, &model.Post{
|
||||||
|
ChannelId: channelID,
|
||||||
|
Message: "❌ " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.API.SendEphemeralPost(req.UserId, &model.Post{
|
||||||
|
ChannelId: channelID,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"✅ Linked **%s** (`%s`)\nHooks: event=%v, metric=%v, issue=%v, comment=%v",
|
||||||
|
project.Name,
|
||||||
|
project.Slug,
|
||||||
|
hooks.EventAlert,
|
||||||
|
hooks.MetricAlert,
|
||||||
|
hooks.Issue,
|
||||||
|
hooks.Comment,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) openEventAlertSetup(
|
||||||
|
args *model.CommandArgs,
|
||||||
|
project *LinkedProject,
|
||||||
|
) (*model.CommandResponse, *model.AppError) {
|
||||||
|
|
||||||
|
if args.TriggerId == "" {
|
||||||
|
return ephemeral("This command must be run from UI"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ephemeral("🛠 Event alert setup is not implemented yet"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) openMetricAlertSetup(
|
||||||
|
args *model.CommandArgs,
|
||||||
|
project *LinkedProject,
|
||||||
|
) (*model.CommandResponse, *model.AppError) {
|
||||||
|
|
||||||
|
if args.TriggerId == "" {
|
||||||
|
return ephemeral("This command must be run from UI"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ephemeral("🛠 Metric alert setup is not implemented yet"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) openIssueSetup(
|
||||||
|
args *model.CommandArgs,
|
||||||
|
project *LinkedProject,
|
||||||
|
) (*model.CommandResponse, *model.AppError) {
|
||||||
|
|
||||||
|
if args.TriggerId == "" {
|
||||||
|
return ephemeral("This command must be run from UI"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ephemeral("🛠 Issue setup is not implemented yet"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) openCommentSetup(
|
||||||
|
args *model.CommandArgs,
|
||||||
|
project *LinkedProject,
|
||||||
|
) (*model.CommandResponse, *model.AppError) {
|
||||||
|
|
||||||
|
if args.TriggerId == "" {
|
||||||
|
return ephemeral("This command must be run from UI"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ephemeral("🛠 Comment setup is not implemented yet"), nil
|
||||||
|
}
|
||||||
@ -56,10 +56,130 @@ type SentryPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LinkedProject struct {
|
type LinkedProject struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
LevelColors map[string]string `json:"level_colors"`
|
Hooks HookSettings `json:"hooks"`
|
||||||
Fields []string `json:"fields"`
|
}
|
||||||
|
|
||||||
|
type HookSettings struct {
|
||||||
|
EventAlert bool `json:"event_alert"`
|
||||||
|
MetricAlert bool `json:"metric_alert"`
|
||||||
|
Issue bool `json:"issue"`
|
||||||
|
Comment bool `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryCommentPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Data struct {
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
ProjectSlug string `json:"project_slug"`
|
||||||
|
CommentID int `json:"comment_id"`
|
||||||
|
IssueID int `json:"issue_id"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
} `json:"data"`
|
||||||
|
Actor struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"actor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryInstallationPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
|
||||||
|
Actor struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"actor"`
|
||||||
|
|
||||||
|
Data struct {
|
||||||
|
Installation struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
|
||||||
|
Organization struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"organization"`
|
||||||
|
|
||||||
|
App struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"app"`
|
||||||
|
} `json:"installation"`
|
||||||
|
} `json:"data"`
|
||||||
|
|
||||||
|
Installation struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"installation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryMetricAlertPayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
|
||||||
|
Data struct {
|
||||||
|
DescriptionText string `json:"description_text"`
|
||||||
|
DescriptionTitle string `json:"description_title"`
|
||||||
|
WebURL string `json:"web_url"`
|
||||||
|
|
||||||
|
MetricAlert struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Projects []string `json:"projects"`
|
||||||
|
|
||||||
|
AlertRule struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Aggregate string `json:"aggregate"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
TimeWindow int `json:"time_window"`
|
||||||
|
Environment *string `json:"environment"`
|
||||||
|
} `json:"alert_rule"`
|
||||||
|
} `json:"metric_alert"`
|
||||||
|
} `json:"data"`
|
||||||
|
|
||||||
|
Installation struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"installation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentryIssuePayload struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
|
||||||
|
Data struct {
|
||||||
|
Issue struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ShortID string `json:"shortId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Culprit string `json:"culprit"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Substatus string `json:"substatus"`
|
||||||
|
WebURL string `json:"web_url"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Count string `json:"count"`
|
||||||
|
UserCount int `json:"userCount"`
|
||||||
|
|
||||||
|
Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"project"`
|
||||||
|
|
||||||
|
IssueType string `json:"issueType"`
|
||||||
|
IssueCategory string `json:"issueCategory"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
} `json:"issue"`
|
||||||
|
} `json:"data"`
|
||||||
|
|
||||||
|
Installation struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"installation"`
|
||||||
|
|
||||||
|
Actor struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"actor"`
|
||||||
}
|
}
|
||||||
|
|||||||
81
server/plugin/utils.go
Normal file
81
server/plugin/utils.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HookType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
HookEventAlert HookType = "event_alert"
|
||||||
|
HookMetricAlert HookType = "metric_alert"
|
||||||
|
HookIssue HookType = "issue"
|
||||||
|
HookComment HookType = "comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getBool(sub map[string]interface{}, key string, def bool) bool {
|
||||||
|
if v, ok := sub[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToStr(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHookEnabled(p *LinkedProject, hook HookType) bool {
|
||||||
|
switch hook {
|
||||||
|
case HookEventAlert:
|
||||||
|
return p.Hooks.EventAlert
|
||||||
|
case HookMetricAlert:
|
||||||
|
return p.Hooks.MetricAlert
|
||||||
|
case HookIssue:
|
||||||
|
return p.Hooks.Issue
|
||||||
|
case HookComment:
|
||||||
|
return p.Hooks.Comment
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) getProjectByChannel(channelID string) (*LinkedProject, error) {
|
||||||
|
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
||||||
|
if data == nil {
|
||||||
|
return nil, errors.New("no projects linked")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
_ = json.Unmarshal(data, &ids)
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
raw, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id)
|
||||||
|
if raw == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var project LinkedProject
|
||||||
|
_ = json.Unmarshal(raw, &project)
|
||||||
|
|
||||||
|
if project.ChannelID == channelID {
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("no Sentry project linked to this channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ephemeral(text string) *model.CommandResponse {
|
||||||
|
return &model.CommandResponse{
|
||||||
|
ResponseType: model.CommandResponseTypeEphemeral,
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -69,9 +69,27 @@ func formatStacktrace(ex *SentryExceptionValue) string {
|
|||||||
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
resource := r.Header.Get("Sentry-Hook-Resource")
|
||||||
|
|
||||||
|
switch resource {
|
||||||
|
case "event_alert":
|
||||||
|
p.handleEventAlert(w, r)
|
||||||
|
case "comment":
|
||||||
|
p.handleComment(w, r)
|
||||||
|
case "metric_alert":
|
||||||
|
p.handleMetricAlert(w, r)
|
||||||
|
case "issue":
|
||||||
|
p.handleIssue(w, r)
|
||||||
|
default:
|
||||||
|
p.API.LogWarn("Unsupported Sentry hook resource", "resource", resource)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleEventAlert(w http.ResponseWriter, r *http.Request) {
|
||||||
var payload SentryPayload
|
var payload SentryPayload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
p.API.LogError("Failed to decode Sentry payload", "error", err.Error())
|
p.API.LogError("Failed to decode Sentry event alert payload", "error", err.Error())
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -123,7 +141,12 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, ex := range event.Exception.Values {
|
for _, ex := range event.Exception.Values {
|
||||||
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
|
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
|
||||||
Title: "Exception",
|
Title: "Exception",
|
||||||
Value: fmt.Sprintf("Type: %s\nValue: %s\nStacktrace:\n%s", ex.Type, ex.Value, formatStacktrace(&ex)),
|
Value: fmt.Sprintf(
|
||||||
|
"Type: %s\nValue: %s\nStacktrace:\n%s",
|
||||||
|
ex.Type,
|
||||||
|
ex.Value,
|
||||||
|
formatStacktrace(&ex),
|
||||||
|
),
|
||||||
Short: true,
|
Short: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -164,3 +187,234 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload SentryCommentPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
p.API.LogError("Failed to decode Sentry comment payload", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := p.getProjectBySlug(payload.Data.ProjectSlug)
|
||||||
|
if err != nil || project == nil || project.ChannelID == "" {
|
||||||
|
p.API.LogWarn("No channel linked for project", "slug", payload.Data.ProjectSlug)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment := &model.SlackAttachment{
|
||||||
|
Color: "#439FE0",
|
||||||
|
Title: "💬 New Sentry comment",
|
||||||
|
Text: fmt.Sprintf(
|
||||||
|
"*%s* commented on issue `%d`\n\n>%s",
|
||||||
|
payload.Actor.Name,
|
||||||
|
payload.Data.IssueID,
|
||||||
|
payload.Data.Comment,
|
||||||
|
),
|
||||||
|
Fields: []*model.SlackAttachmentField{
|
||||||
|
{Title: "Project", Value: project.Name, Short: true},
|
||||||
|
{Title: "Action", Value: payload.Action, Short: true},
|
||||||
|
{Title: "Comment ID", Value: strconv.Itoa(payload.Data.CommentID), Short: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
post := &model.Post{
|
||||||
|
UserId: p.botUserID,
|
||||||
|
ChannelId: project.ChannelID,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"attachments": []*model.SlackAttachment{attachment},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.API.CreatePost(post); err != nil {
|
||||||
|
p.API.LogError("Failed to create comment post", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) getProjectBySlug(slug string) (*LinkedProject, error) {
|
||||||
|
// Получаем все ключи плагина
|
||||||
|
keys, appErr := p.API.KVList(0, 1000)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
// интересуют только наши проекты
|
||||||
|
if !strings.HasPrefix(key, "ru.loop.plugin.sentry:project:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, appErr := p.API.KVGet(key)
|
||||||
|
if appErr != nil || data == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var project LinkedProject
|
||||||
|
if err := json.Unmarshal(data, &project); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if project.Slug == slug {
|
||||||
|
return &project, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleMetricAlert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload SentryMetricAlertPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
p.API.LogError("Failed to decode metric alert payload", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alert := payload.Data.MetricAlert
|
||||||
|
rule := alert.AlertRule
|
||||||
|
|
||||||
|
// Обычно metric alerts привязаны к проекту по slug
|
||||||
|
var project *LinkedProject
|
||||||
|
if len(alert.Projects) > 0 {
|
||||||
|
project, _ = p.getProjectBySlug(alert.Projects[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID := ""
|
||||||
|
if project != nil {
|
||||||
|
channelID = project.ChannelID
|
||||||
|
}
|
||||||
|
|
||||||
|
if channelID == "" {
|
||||||
|
p.API.LogWarn("No channel linked for metric alert", "projects", alert.Projects)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
color := "#E01E5A" // default: critical
|
||||||
|
switch payload.Action {
|
||||||
|
case "resolved":
|
||||||
|
color = "#2EB67D"
|
||||||
|
case "warning":
|
||||||
|
color = "#ECB22E"
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment := &model.SlackAttachment{
|
||||||
|
Color: color,
|
||||||
|
Title: payload.Data.DescriptionTitle,
|
||||||
|
TitleLink: payload.Data.WebURL,
|
||||||
|
Text: payload.Data.DescriptionText,
|
||||||
|
Fields: []*model.SlackAttachmentField{
|
||||||
|
{
|
||||||
|
Title: "Rule",
|
||||||
|
Value: rule.Name,
|
||||||
|
Short: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Aggregate",
|
||||||
|
Value: rule.Aggregate,
|
||||||
|
Short: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Query",
|
||||||
|
Value: rule.Query,
|
||||||
|
Short: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Window (min)",
|
||||||
|
Value: strconv.Itoa(rule.TimeWindow),
|
||||||
|
Short: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Action",
|
||||||
|
Value: payload.Action,
|
||||||
|
Short: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
post := &model.Post{
|
||||||
|
UserId: p.botUserID,
|
||||||
|
ChannelId: channelID,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"attachments": []*model.SlackAttachment{attachment},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.API.CreatePost(post); err != nil {
|
||||||
|
p.API.LogError("Failed to create metric alert post", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload SentryIssuePayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
p.API.LogError("Failed to decode issue payload", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := payload.Data.Issue
|
||||||
|
|
||||||
|
project, err := p.getProjectBySlug(issue.Project.Slug)
|
||||||
|
if err != nil || project == nil || project.ChannelID == "" {
|
||||||
|
p.API.LogWarn("No channel linked for issue", "project", issue.Project.Slug)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
color := "#E01E5A" // default: red
|
||||||
|
switch payload.Action {
|
||||||
|
case "resolved":
|
||||||
|
color = "#2EB67D"
|
||||||
|
case "archived":
|
||||||
|
color = "#6B7280"
|
||||||
|
case "unresolved":
|
||||||
|
color = "#F97316"
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment := &model.SlackAttachment{
|
||||||
|
Color: color,
|
||||||
|
Title: fmt.Sprintf("[%s] %s", issue.ShortID, issue.Title),
|
||||||
|
TitleLink: issue.WebURL,
|
||||||
|
Text: fmt.Sprintf(
|
||||||
|
"**Action:** `%s`\n**Status:** `%s`\n**Level:** `%s`\n**Priority:** `%s`",
|
||||||
|
payload.Action,
|
||||||
|
issue.Status,
|
||||||
|
issue.Level,
|
||||||
|
issue.Priority,
|
||||||
|
),
|
||||||
|
Fields: []*model.SlackAttachmentField{
|
||||||
|
{Title: "Project", Value: issue.Project.Name, Short: true},
|
||||||
|
{Title: "Platform", Value: issue.Platform, Short: true},
|
||||||
|
{Title: "Type", Value: issue.IssueType, Short: true},
|
||||||
|
{Title: "Category", Value: issue.IssueCategory, Short: true},
|
||||||
|
{Title: "Events", Value: issue.Count, Short: true},
|
||||||
|
{Title: "Users", Value: strconv.Itoa(issue.UserCount), Short: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
post := &model.Post{
|
||||||
|
UserId: p.botUserID,
|
||||||
|
ChannelId: project.ChannelID,
|
||||||
|
Props: map[string]interface{}{
|
||||||
|
"attachments": []*model.SlackAttachment{attachment},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.API.CreatePost(post); err != nil {
|
||||||
|
p.API.LogError("Failed to create issue post", "error", err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user