diff --git a/server/plugin/api.go b/server/plugin/api.go index dbf9375..a2477ab 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -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 + 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 } diff --git a/server/plugin/commands.go b/server/plugin/commands.go index 3843ec8..08db583 100644 --- a/server/plugin/commands.go +++ b/server/plugin/commands.go @@ -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 + } +} diff --git a/server/plugin/modal.go b/server/plugin/modal.go new file mode 100644 index 0000000..612015f --- /dev/null +++ b/server/plugin/modal.go @@ -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 +} diff --git a/server/plugin/sentry_types.go b/server/plugin/sentry_types.go index 7d3a145..790c028 100644 --- a/server/plugin/sentry_types.go +++ b/server/plugin/sentry_types.go @@ -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"` } diff --git a/server/plugin/utils.go b/server/plugin/utils.go new file mode 100644 index 0000000..13e96b8 --- /dev/null +++ b/server/plugin/utils.go @@ -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, + } +} diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index adc47f9..53371a7 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -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) +}