343 lines
8.7 KiB
Go
343 lines
8.7 KiB
Go
package plugin
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"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()
|
||
|
||
if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" {
|
||
return nil, errors.New("sentry is not configured")
|
||
}
|
||
|
||
url := fmt.Sprintf(
|
||
"%s/api/0/projects/%s/%s/",
|
||
strings.TrimRight(cfg.SentryUrl, "/"),
|
||
cfg.SentryOrganisationName,
|
||
projectSlug,
|
||
)
|
||
|
||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req.Header.Set("Authorization", "Bearer "+cfg.SentryAuthToken)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.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 nil, err
|
||
}
|
||
|
||
if result.ID == "" {
|
||
return nil, errors.New("project id not found")
|
||
}
|
||
|
||
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) {
|
||
if len(split) < 3 {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "❌ Usage: /sentry link <project_slug>",
|
||
}, nil
|
||
}
|
||
|
||
projectSlug := split[2]
|
||
channelID := args.ChannelId
|
||
|
||
project, err := p.fetchSentryProject(projectSlug)
|
||
if err != nil {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "❌ " + err.Error(),
|
||
}, 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(
|
||
"✅ Linked **%s** (`%s`) to this channel",
|
||
project.Name,
|
||
project.Slug,
|
||
),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) commandUnlink(args *model.CommandArgs, split []string) (*model.CommandResponse, *model.AppError) {
|
||
if len(split) < 3 {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "❌ Usage: /sentry unlink <project_slug>",
|
||
}, nil
|
||
}
|
||
|
||
slug := split[2]
|
||
|
||
data, _ := p.API.KVGet("ru.loop.plugin.sentry:projects")
|
||
if data == nil {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "ℹ️ No linked projects",
|
||
}, nil
|
||
}
|
||
|
||
var projects []string
|
||
_ = 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 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)
|
||
}
|
||
|
||
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: fmt.Sprintf(
|
||
"✅ Unlinked **%s** (`%s`)",
|
||
removed.Name,
|
||
removed.Slug,
|
||
),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) commandList(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
|
||
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,
|
||
Text: "_No linked Sentry projects_",
|
||
}, nil
|
||
}
|
||
|
||
var lines []string
|
||
|
||
for _, id := range projects {
|
||
pData, _ := p.API.KVGet("ru.loop.plugin.sentry:project:" + id)
|
||
if pData == nil {
|
||
continue
|
||
}
|
||
|
||
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,
|
||
fmt.Sprintf(
|
||
"• **%s** (`%s`) → %s",
|
||
project.Name,
|
||
project.Slug,
|
||
channelName,
|
||
),
|
||
)
|
||
}
|
||
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n"),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) commandHelp() *model.CommandResponse {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: `**Sentry plugin commands**
|
||
|
||
• /sentry link <project_slug> — link project to channel
|
||
• /sentry help — show help`,
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) commandSetup(args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
|
||
if args.TriggerId == "" {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "This command must be run from Mattermost UI",
|
||
}, nil
|
||
}
|
||
|
||
// Получаем каналы пользователя
|
||
channels, _ := p.API.GetChannelsForTeamForUser(args.TeamId, args.UserId, false)
|
||
options := []*model.PostActionOptions{}
|
||
for _, ch := range channels {
|
||
options = append(options, &model.PostActionOptions{
|
||
Text: ch.DisplayName,
|
||
Value: ch.Id,
|
||
})
|
||
}
|
||
|
||
// Поля 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{
|
||
{
|
||
DisplayName: "Channel",
|
||
Name: "channel_id",
|
||
Type: "select",
|
||
Options: options,
|
||
Default: args.ChannelId,
|
||
},
|
||
{
|
||
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",
|
||
Dialog: *modal,
|
||
}
|
||
|
||
if appErr := p.API.OpenInteractiveDialog(req); appErr != nil {
|
||
return &model.CommandResponse{
|
||
ResponseType: model.CommandResponseTypeEphemeral,
|
||
Text: "❌ Failed to open setup dialog: " + appErr.Error(),
|
||
}, nil
|
||
}
|
||
|
||
return &model.CommandResponse{}, nil
|
||
}
|