refactor Sentry plugin commands and enhance autocomplete functionality. Introduced a new method for fetching project details and improved error handling. Updated command responses for linking and unlinking projects, and added support for additional fields in webhook setup.

This commit is contained in:
Кирилл Моос 2026-01-02 10:50:16 +03:00
parent e04079cb0b
commit ced59c39d2
5 changed files with 258 additions and 103 deletions

View File

@ -29,8 +29,7 @@ func (p *Plugin) registerCommands() error {
DisplayName: "Sentry", DisplayName: "Sentry",
Description: "Manage Sentry alerts and integrations", Description: "Manage Sentry alerts and integrations",
AutoComplete: true, AutoComplete: true,
AutoCompleteDesc: "Available commands: help, link, unlink, list", AutocompleteData: p.getAutocompleteData(),
AutoCompleteHint: "help | link <project> | unlink <project> | list",
} }
if err := p.API.RegisterCommand(command); err != nil { 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
}

View File

@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/model"
@ -34,13 +35,21 @@ func (p *Plugin) ensureBot() error {
return nil return nil
} }
func (p *Plugin) getChannelForProject(project string) (string, error) { func (p *Plugin) getChannelForProject(projectID string) (string, error) {
key := "sentry:project:" + project key := "ru.loop.plugin.sentry:project:" + projectID
data, appErr := p.API.KVGet(key) data, appErr := p.API.KVGet(key)
if appErr != nil || data == nil { if appErr != nil {
return "", appErr 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
} }

View File

@ -12,11 +12,26 @@ import (
"github.com/mattermost/mattermost/server/public/model" "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() cfg := p.GetConfiguration()
if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { 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( url := fmt.Sprintf(
@ -28,7 +43,7 @@ func (p *Plugin) fetchSentryProjectID(projectSlug string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return "", err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+cfg.SentryAuthToken) 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} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) 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 { 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 { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err return nil, err
} }
if result.ID == "" { 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) { 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 }, 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] projectSlug := split[2]
channelID := args.ChannelId channelID := args.ChannelId
// fetch project id from Sentry project, err := p.fetchSentryProject(projectSlug)
projectID, err := p.fetchSentryProjectID(projectSlug)
if err != nil { if err != nil {
p.API.LogError("Failed to fetch Sentry project", "project", projectSlug, "err", err.Error())
return &model.CommandResponse{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Failed to fetch Sentry project: " + err.Error(), Text: "❌ " + err.Error(),
}, nil }, nil
} }
// projectID -> channel project.ChannelID = channelID
if err := p.API.KVSet("sentry:project:"+projectID, []byte(channelID)); err != nil {
// сохраняем проект
bytes, _ := json.Marshal(project)
if err := p.API.KVSet("ru.loop.plugin.sentry:project:"+project.ID, bytes); err != nil {
return &model.CommandResponse{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
Text: "❌ Failed to save project mapping", Text: "❌ Failed to save project",
}, nil }, nil
} }
// store list of linked projects // обновляем список IDs
data, _ := p.API.KVGet("sentry:projects") data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
var projects []string var projects []string
if data != nil { if data != nil {
projects = strings.Split(string(data), ",") _ = json.Unmarshal(data, &projects)
} }
found := false exists := false
for _, id := range projects { for _, id := range projects {
if id == projectID { if id == project.ID {
found = true exists = true
break break
} }
} }
if !found { if !exists {
projects = append(projects, projectID) projects = append(projects, project.ID)
} }
_ = p.API.KVSet("sentry:projects", []byte(strings.Join(projects, ","))) updated, _ := json.Marshal(projects)
_ = p.API.KVSet("ru.loop.plugin.sentry:projects", updated)
p.API.CreatePost(&model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Message: fmt.Sprintf(
"✅ Sentry project `%s` (id: %s) linked to this channel",
projectSlug,
projectID,
),
})
return &model.CommandResponse{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
Text: "🔗 Linked project `" + projectSlug + "`", Text: fmt.Sprintf(
"✅ Linked **%s** (`%s`) to this channel",
project.Name,
project.Slug,
),
}, nil }, nil
} }
@ -141,44 +151,74 @@ func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model.
}, nil }, nil
} }
project := split[2] slug := split[2]
// удаляем привязку project -> channel data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
p.API.KVDelete("sentry:project:" + project) if data == nil {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: " No linked projects",
}, nil
}
// обновляем список проектов
data, _ := p.API.KVGet("sentry:projects")
var projects []string var projects []string
if data != nil { _ = json.Unmarshal(data, &projects)
projects = strings.Split(string(data), ",")
}
var newProjects []string var (
for _, p := range projects { newProjects []string
if p != project { removed *LinkedProject
newProjects = append(newProjects, p) )
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{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
Text: "✅ Unlinked project `" + project + "`", Text: fmt.Sprintf(
"✅ Unlinked **%s** (`%s`)",
removed.Name,
removed.Slug,
),
}, nil }, nil
} }
func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
// Для MVP мы перебираем все известные ключи в KV data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
// Так как KVListKeys нет, можно завести slice всех проектов, if data == nil {
// которые были когда-либо linked через /sentry link return &model.CommandResponse{
// Эти проекты мы храним отдельно в ключе "sentry:projects" ResponseType: model.CommandResponseTypeEphemeral,
data, _ := p.API.KVGet("sentry:projects") Text: "_No linked Sentry projects_",
var projects []string }, nil
if data != nil {
projects = strings.Split(string(data), ",")
} }
var projects []string
_ = json.Unmarshal(data, &projects)
if len(projects) == 0 { if len(projects) == 0 {
return &model.CommandResponse{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
@ -188,27 +228,34 @@ func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *
var lines []string var lines []string
for _, project := range projects { for _, id := range projects {
channelData, _ := p.API.KVGet("sentry:project:" + project) pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id)
if channelData == nil { if pData == nil {
continue continue
} }
channelID := string(channelData)
channel, err := p.API.GetChannel(channelID) var project LinkedProject
channelName := channelID _ = json.Unmarshal(pData, &project)
if err == nil && channel != nil {
channelName = "~" + channel.Name 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{ return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral, ResponseType: model.CommandResponseTypeEphemeral,
Text: text, Text: "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n"),
}, nil }, nil
} }
@ -230,12 +277,8 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
}, nil }, nil
} }
channels, _ := p.API.GetChannelsForTeamForUser( // Получаем каналы пользователя
args.TeamId, channels, _ := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false)
args.UserId,
false,
)
options := []*model.PostActionOptions{} options := []*model.PostActionOptions{}
for _, ch := range channels { for _, ch := range channels {
options = append(options, &model.PostActionOptions{ 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{ modal := &model.Dialog{
Title: "Sentry Setup", Title: "Sentry Setup",
CallbackId: "sentry_setup", CallbackId: "sentry_setup",
@ -257,21 +303,39 @@ func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse,
Default: args.ChannelId, Default: args.ChannelId,
}, },
{ {
DisplayName: "Sentry Project", DisplayName: "Sentry Project Slug",
Name: "project", Name: "project_slug",
Type: "text", 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{ req := model.OpenDialogRequest{
TriggerId: args.TriggerId, TriggerId: args.TriggerId,
URL: "/plugins/" + "ru.loop.plugin.sentry" + "/dialog/submit", URL: "/plugins/ru.loop.plugin.sentry/dialog/submit",
Dialog: *modal, Dialog: *modal,
} }
if appErr := p.API.OpenInteractiveDialog(req); appErr != nil { 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 return &model.CommandResponse{}, nil

View File

@ -54,3 +54,12 @@ type SentryPayload struct {
TriggeredRule string `json:"triggered_rule"` TriggeredRule string `json:"triggered_rule"`
} `json:"data"` } `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"`
}

View File

@ -38,6 +38,22 @@ func getTagFromArray(tags [][]string, key string) string {
return "" 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 { func formatStacktrace(ex *SentryExceptionValue) string {
if ex == nil || len(ex.Stacktrace.Frames) == 0 { if ex == nil || len(ex.Stacktrace.Frames) == 0 {
return "" return ""
@ -69,6 +85,13 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
return 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") environment := getTagFromArray(event.Tags, "environment")
release := getTagFromArray(event.Tags, "release") release := getTagFromArray(event.Tags, "release")
user := getTagFromArray(event.Tags, "user") user := getTagFromArray(event.Tags, "user")
@ -79,6 +102,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
TitleLink: event.WebURL, TitleLink: event.WebURL,
Text: event.Message, Text: event.Message,
Fields: []*model.SlackAttachmentField{ 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: "Project ID", Value: strconv.Itoa(event.Project), Short: true},
{Title: "Issue ID", Value: event.IssueID, Short: true}, {Title: "Issue ID", Value: event.IssueID, Short: true},
{Title: "Environment", Value: environment, 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 { if event.Exception != nil && len(event.Exception.Values) > 0 {
// 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,
// }) })
// } }
// } }
for _, tag := range event.Tags { for _, tag := range event.Tags {
if len(tag) != 2 { if len(tag) != 2 {