diff --git a/server/plugin/api.go b/server/plugin/api.go index 00a2e1c..dbf9375 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -29,8 +29,7 @@ func (p *Plugin) registerCommands() error { DisplayName: "Sentry", Description: "Manage Sentry alerts and integrations", AutoComplete: true, - AutoCompleteDesc: "Available commands: help, link, unlink, list", - AutoCompleteHint: "help | link | unlink | list", + AutocompleteData: p.getAutocompleteData(), } if err := p.API.RegisterCommand(command); err != nil { @@ -67,3 +66,49 @@ func (p *Plugin) ExecuteCommand( } } + +func (p *Plugin) getAutocompleteData() *model.AutocompleteData { + sentry := model.NewAutocompleteData( + "sentry", + "", + "Sentry integration commands", + ) + + setup := model.NewAutocompleteData( + "setup", + "", + "Initial setup of Sentry integration", + ) + + help := model.NewAutocompleteData( + "help", + "", + "Show help for Sentry commands", + ) + + list := model.NewAutocompleteData( + "list", + "", + "List linked Sentry projects", + ) + + link := model.NewAutocompleteData( + "link", + "[project]", + "Link a Sentry project to this channel", + ) + + unlink := model.NewAutocompleteData( + "unlink", + "[project]", + "Unlink a Sentry project from this channel", + ) + + sentry.AddCommand(setup) + sentry.AddCommand(help) + sentry.AddCommand(list) + sentry.AddCommand(link) + sentry.AddCommand(unlink) + + return sentry +} diff --git a/server/plugin/bot.go b/server/plugin/bot.go index cee10ee..f77133f 100644 --- a/server/plugin/bot.go +++ b/server/plugin/bot.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/json" "fmt" "github.com/mattermost/mattermost/server/public/model" @@ -34,13 +35,21 @@ func (p *Plugin) ensureBot() error { return nil } -func (p *Plugin) getChannelForProject(project string) (string, error) { - key := "sentry:project:" + project +func (p *Plugin) getChannelForProject(projectID string) (string, error) { + key := "ru.loop.plugin.sentry:project:" + projectID data, appErr := p.API.KVGet(key) - if appErr != nil || data == nil { + if appErr != nil { return "", appErr } + if data == nil { + return "", nil + } - return string(data), nil + var project LinkedProject + if err := json.Unmarshal(data, &project); err != nil { + return "", err + } + + return project.ChannelID, nil } diff --git a/server/plugin/commands.go b/server/plugin/commands.go index 707fe4e..3843ec8 100644 --- a/server/plugin/commands.go +++ b/server/plugin/commands.go @@ -12,11 +12,26 @@ import ( "github.com/mattermost/mattermost/server/public/model" ) -func (p *Plugin) fetchSentryProjectID(projectSlug string) (string, error) { +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() if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { - return "", errors.New("sentry is not configured") + return nil, errors.New("sentry is not configured") } url := fmt.Sprintf( @@ -28,7 +43,7 @@ func (p *Plugin) fetchSentryProjectID(projectSlug string) (string, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return "", err + return nil, err } req.Header.Set("Authorization", "Bearer "+cfg.SentryAuthToken) @@ -37,27 +52,34 @@ func (p *Plugin) fetchSentryProjectID(projectSlug string) (string, error) { client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { - return "", err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("sentry api error (%d): %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("sentry api error (%d): %s", resp.StatusCode, body) } var result struct { - ID string `json:"id"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", err + return nil, err } if result.ID == "" { - return "", errors.New("project id not found") + return nil, errors.New("project id not found") } - return result.ID, nil + return &LinkedProject{ + ID: result.ID, + Slug: result.Slug, + Name: result.Name, + }, nil } func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.CommandResponse, *model.AppError) { @@ -68,68 +90,56 @@ func (p *Plugin) commandLink(args *model.CommandArgs, split []string) (*model.Co }, nil } - cfg := p.GetConfiguration() - if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { - return &model.CommandResponse{ - ResponseType: model.CommandResponseTypeEphemeral, - Text: "❌ Sentry is not configured. Please fill plugin settings.", - }, nil - } - projectSlug := split[2] channelID := args.ChannelId - // fetch project id from Sentry - projectID, err := p.fetchSentryProjectID(projectSlug) + project, err := p.fetchSentryProject(projectSlug) if err != nil { - p.API.LogError("Failed to fetch Sentry project", "project", projectSlug, "err", err.Error()) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, - Text: "❌ Failed to fetch Sentry project: " + err.Error(), + Text: "❌ " + err.Error(), }, nil } - // projectID -> channel - if err := p.API.KVSet("sentry:project:"+projectID, []byte(channelID)); err != 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 mapping", + Text: "❌ Failed to save project", }, nil } - // store list of linked projects - data, _ := p.API.KVGet("sentry:projects") + // обновляем список IDs + data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") var projects []string if data != nil { - projects = strings.Split(string(data), ",") + _ = json.Unmarshal(data, &projects) } - found := false + exists := false for _, id := range projects { - if id == projectID { - found = true + if id == project.ID { + exists = true break } } - if !found { - projects = append(projects, projectID) + if !exists { + projects = append(projects, project.ID) } - _ = p.API.KVSet("sentry:projects", []byte(strings.Join(projects, ","))) - - p.API.CreatePost(&model.Post{ - UserId: p.botUserID, - ChannelId: channelID, - Message: fmt.Sprintf( - "✅ Sentry project `%s` (id: %s) linked to this channel", - projectSlug, - projectID, - ), - }) + updated, _ := json.Marshal(projects) + _ = p.API.KVSet("ru.loop.plugin.sentry:projects", updated) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, - Text: "🔗 Linked project `" + projectSlug + "`", + Text: fmt.Sprintf( + "✅ Linked **%s** (`%s`) to this channel", + project.Name, + project.Slug, + ), }, nil } @@ -141,44 +151,74 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model. }, nil } - project := split[2] + slug := split[2] - // удаляем привязку project -> channel - p.API.KVDelete("sentry:project:" + project) + data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") + if data == nil { + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "ℹ️ No linked projects", + }, nil + } - // обновляем список проектов - data, _ := p.API.KVGet("sentry:projects") var projects []string - if data != nil { - projects = strings.Split(string(data), ",") - } + _ = json.Unmarshal(data, &projects) - var newProjects []string - for _, p := range projects { - if p != project { - newProjects = append(newProjects, p) + var ( + newProjects []string + removed *LinkedProject + ) + + for _, id := range projects { + pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id) + if pData == nil { + continue } + + var proj LinkedProject + _ = json.Unmarshal(pData, &proj) + + if proj.Slug == slug { + removed = &proj + _ = p.API.KVDelete("ru.loop.plugin.sentry:project:" + id) + continue + } + + newProjects = append(newProjects, id) } - p.API.KVSet("sentry:projects", []byte(strings.Join(newProjects, ","))) + if removed == nil { + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "❌ Project not found", + }, nil + } + + bytes, _ := json.Marshal(newProjects) + _ = p.API.KVSet("ru.loop.plugin.sentry:projects", bytes) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, - Text: "✅ Unlinked project `" + project + "`", + Text: fmt.Sprintf( + "✅ Unlinked **%s** (`%s`)", + removed.Name, + removed.Slug, + ), }, nil } func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { - // Для MVP мы перебираем все известные ключи в KV - // Так как KVListKeys нет, можно завести slice всех проектов, - // которые были когда-либо linked через /sentry link - // Эти проекты мы храним отдельно в ключе "sentry:projects" - data, _ := p.API.KVGet("sentry:projects") - var projects []string - if data != nil { - projects = strings.Split(string(data), ",") + data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects") + if data == nil { + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "_No linked Sentry projects_", + }, nil } + var projects []string + _ = json.Unmarshal(data, &projects) + if len(projects) == 0 { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, @@ -188,27 +228,34 @@ func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, * var lines []string - for _, project := range projects { - channelData, _ := p.API.KVGet("sentry:project:" + project) - if channelData == nil { + for _, id := range projects { + pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id) + if pData == nil { continue } - channelID := string(channelData) - channel, err := p.API.GetChannel(channelID) - channelName := channelID - if err == nil && channel != nil { - channelName = "~" + channel.Name + var project LinkedProject + _ = json.Unmarshal(pData, &project) + + channelName := project.ChannelID + if ch, err := p.API.GetChannel(project.ChannelID); err == nil { + channelName = "~" + ch.Name } - lines = append(lines, "• "+project+" → "+channelName) + lines = append( + lines, + fmt.Sprintf( + "• **%s** (`%s`) → %s", + project.Name, + project.Slug, + channelName, + ), + ) } - text := "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n") - return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, - Text: text, + Text: "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n"), }, nil } @@ -230,12 +277,8 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse, }, nil } - channels, _ := p.API.GetChannelsForTeamForUser( - args.TeamId, - args.UserId, - false, - ) - + // Получаем каналы пользователя + channels, _ := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false) options := []*model.PostActionOptions{} for _, ch := range channels { options = append(options, &model.PostActionOptions{ @@ -244,6 +287,9 @@ 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", @@ -257,21 +303,39 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse, Default: args.ChannelId, }, { - DisplayName: "Sentry Project", - Name: "project", + DisplayName: "Sentry Project Slug", + Name: "project_slug", Type: "text", + Placeholder: "Enter project slug", }, + { + 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"}, }, } req := model.OpenDialogRequest{ TriggerId: args.TriggerId, - URL: "/plugins/" + "ru.loop.plugin.sentry" + "/dialog/submit", + URL: "/plugins/ru.loop.plugin.sentry/dialog/submit", Dialog: *modal, } if appErr := p.API.OpenInteractiveDialog(req); appErr != nil { - return nil, appErr + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: "❌ Failed to open setup dialog: " + appErr.Error(), + }, nil } return &model.CommandResponse{}, nil diff --git a/server/plugin/sentry_types.go b/server/plugin/sentry_types.go index 4adab1e..7d3a145 100644 --- a/server/plugin/sentry_types.go +++ b/server/plugin/sentry_types.go @@ -54,3 +54,12 @@ type SentryPayload struct { TriggeredRule string `json:"triggered_rule"` } `json:"data"` } + +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"` +} diff --git a/server/plugin/webhook.go b/server/plugin/webhook.go index fc34da6..adc47f9 100644 --- a/server/plugin/webhook.go +++ b/server/plugin/webhook.go @@ -38,6 +38,22 @@ func getTagFromArray(tags [][]string, key string) string { return "" } +func (p *Plugin) getProject(projectID string) (*LinkedProject, error) { + key := "ru.loop.plugin.sentry:project:" + projectID + + data, appErr := p.API.KVGet(key) + if appErr != nil || data == nil { + return nil, appErr + } + + var project LinkedProject + if err := json.Unmarshal(data, &project); err != nil { + return nil, err + } + + return &project, nil +} + func formatStacktrace(ex *SentryExceptionValue) string { if ex == nil || len(ex.Stacktrace.Frames) == 0 { return "" @@ -69,6 +85,13 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { return } + project, err := p.getProject(strconv.Itoa(event.Project)) + if err != nil || project == nil || project.ChannelID == "" { + p.API.LogWarn("No channel linked for project", "project", event.Project) + w.WriteHeader(http.StatusOK) + return + } + environment := getTagFromArray(event.Tags, "environment") release := getTagFromArray(event.Tags, "release") user := getTagFromArray(event.Tags, "user") @@ -79,6 +102,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { TitleLink: event.WebURL, Text: event.Message, Fields: []*model.SlackAttachmentField{ + { + Title: "Project", + Value: fmt.Sprintf("%s (`%s`)", project.Name, project.Slug), + Short: true, + }, {Title: "Project ID", Value: strconv.Itoa(event.Project), Short: true}, {Title: "Issue ID", Value: event.IssueID, Short: true}, {Title: "Environment", Value: environment, Short: true}, @@ -91,15 +119,15 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { }, } - // if event.Exception != nil && len(event.Exception.Values) > 0 { - // 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)), - // Short: true, - // }) - // } - // } + if event.Exception != nil && len(event.Exception.Values) > 0 { + 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)), + Short: true, + }) + } + } for _, tag := range event.Tags { if len(tag) != 2 {