343 lines
8.7 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}