add new commands
This commit is contained in:
parent
ced59c39d2
commit
1a056be4c6
@ -12,6 +12,7 @@ import (
|
||||
func (p *Plugin) InitApi() {
|
||||
p.router = mux.NewRouter()
|
||||
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) {
|
||||
@ -52,7 +53,12 @@ func (p *Plugin) ExecuteCommand(
|
||||
|
||||
switch split[1] {
|
||||
case "setup":
|
||||
return p.commandSetup(args)
|
||||
if len(split) == 2 {
|
||||
return p.commandSetup(args)
|
||||
}
|
||||
|
||||
return p.commandSetupHook(args, split[2])
|
||||
|
||||
case "link":
|
||||
return p.commandLink(args, split)
|
||||
case "unlink":
|
||||
@ -68,16 +74,18 @@ func (p *Plugin) ExecuteCommand(
|
||||
}
|
||||
|
||||
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
||||
// root: /sentry
|
||||
sentry := model.NewAutocompleteData(
|
||||
"sentry",
|
||||
"",
|
||||
"Sentry integration commands",
|
||||
)
|
||||
|
||||
// /sentry setup [hook]
|
||||
setup := model.NewAutocompleteData(
|
||||
"setup",
|
||||
"",
|
||||
"Initial setup of Sentry integration",
|
||||
"[hook]",
|
||||
"Initial setup or configure a specific webhook",
|
||||
)
|
||||
|
||||
help := model.NewAutocompleteData(
|
||||
@ -94,21 +102,39 @@ func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
||||
|
||||
link := model.NewAutocompleteData(
|
||||
"link",
|
||||
"[project]",
|
||||
"[project_slug]",
|
||||
"Link a Sentry project to this channel",
|
||||
)
|
||||
|
||||
unlink := model.NewAutocompleteData(
|
||||
"unlink",
|
||||
"[project]",
|
||||
"[project_slug]",
|
||||
"Unlink a Sentry project from this channel",
|
||||
)
|
||||
|
||||
// register top-level commands
|
||||
sentry.AddCommand(setup)
|
||||
sentry.AddCommand(help)
|
||||
sentry.AddCommand(list)
|
||||
sentry.AddCommand(link)
|
||||
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
|
||||
}
|
||||
|
||||
@ -12,21 +12,6 @@ import (
|
||||
"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) {
|
||||
cfg := p.GetConfiguration()
|
||||
|
||||
@ -92,8 +77,14 @@ func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.Co
|
||||
|
||||
projectSlug := split[2]
|
||||
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 {
|
||||
return &model.CommandResponse{
|
||||
ResponseType: model.CommandResponseTypeEphemeral,
|
||||
@ -101,38 +92,6 @@ func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.Co
|
||||
}, 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{
|
||||
ResponseType: model.CommandResponseTypeEphemeral,
|
||||
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{}
|
||||
for _, ch := range channels {
|
||||
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{
|
||||
Title: "Sentry Setup",
|
||||
CallbackId: "sentry_setup",
|
||||
SubmitLabel: "Save",
|
||||
Elements: []model.DialogElement{
|
||||
// ───── General ─────
|
||||
{
|
||||
DisplayName: "Channel",
|
||||
Name: "channel_id",
|
||||
DisplayName: "Default channel",
|
||||
Name: "default_channel_id",
|
||||
Type: "select",
|
||||
Options: options,
|
||||
Default: args.ChannelId,
|
||||
},
|
||||
{
|
||||
DisplayName: "Sentry Project Slug",
|
||||
DisplayName: "Sentry project slug",
|
||||
Name: "project_slug",
|
||||
Type: "text",
|
||||
Placeholder: "Enter project slug",
|
||||
Placeholder: "frontend-app",
|
||||
},
|
||||
{
|
||||
DisplayName: "Webhook fields",
|
||||
Name: "fields",
|
||||
Type: "text",
|
||||
Default: fieldList,
|
||||
Placeholder: "Comma-separated list of fields to show in webhook",
|
||||
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"},
|
||||
|
||||
// ───── Webhook types ─────
|
||||
{DisplayName: "Event alerts", Name: "hook_event_alert", Type: "bool", Default: "true"},
|
||||
{DisplayName: "Metric alerts", Name: "hook_metric_alert", Type: "bool", Default: "true"},
|
||||
{DisplayName: "Issues", Name: "hook_issue", Type: "bool", Default: "true"},
|
||||
{DisplayName: "Comments", Name: "hook_comment", Type: "bool", Default: "true"},
|
||||
},
|
||||
}
|
||||
|
||||
@ -340,3 +296,74 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
LevelColors map[string]string `json:"level_colors"`
|
||||
Fields []string `json:"fields"`
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Hooks HookSettings `json:"hooks"`
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -123,7 +141,12 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
for _, ex := range event.Exception.Values {
|
||||
attachment.Fields = append(attachment.Fields, &model.SlackAttachmentField{
|
||||
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,
|
||||
})
|
||||
}
|
||||
@ -164,3 +187,234 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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