add new commands

This commit is contained in:
Кирилл Моос 2026-01-23 20:54:16 +03:00
parent ced59c39d2
commit 1a056be4c6
6 changed files with 697 additions and 85 deletions

View File

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

View File

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

View File

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

View File

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