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:
parent
e04079cb0b
commit
ced59c39d2
@ -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 <project> | unlink <project> | 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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
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
|
||||
removed *LinkedProject
|
||||
)
|
||||
|
||||
for _, id := range projects {
|
||||
pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id)
|
||||
if pData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var newProjects []string
|
||||
for _, p := range projects {
|
||||
if p != project {
|
||||
newProjects = append(newProjects, p)
|
||||
}
|
||||
var proj LinkedProject
|
||||
_ = json.Unmarshal(pData, &proj)
|
||||
|
||||
if proj.Slug == slug {
|
||||
removed = &proj
|
||||
_ = p.API.KVDelete("ru.loop.plugin.sentry:project:" + id)
|
||||
continue
|
||||
}
|
||||
|
||||
p.API.KVSet("sentry:projects", []byte(strings.Join(newProjects, ",")))
|
||||
newProjects = append(newProjects, id)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user