package plugin import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/mattermost/mattermost/server/public/model" ) func (p *Plugin) fetchSentryProjectID(projectSlug string) (string, error) { cfg := p.GetConfiguration() if cfg.SentryUrl == "" || cfg.SentryOrganisationName == "" || cfg.SentryAuthToken == "" { return "", 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 "", 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 "", 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)) } var result struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } if result.ID == "" { return "", errors.New("project id not found") } return result.ID, 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 ", }, 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) 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(), }, nil } // projectID -> channel if err := p.API.KVSet("sentry:project:"+projectID, []byte(channelID)); err != nil { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "❌ Failed to save project mapping", }, nil } // store list of linked projects data, _ := p.API.KVGet("sentry:projects") var projects []string if data != nil { projects = strings.Split(string(data), ",") } found := false for _, id := range projects { if id == projectID { found = true break } } if !found { projects = append(projects, projectID) } _ = 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, ), }) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "🔗 Linked project `" + projectSlug + "`", }, 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 ", }, nil } project := split[2] // удаляем привязку project -> channel p.API.KVDelete("sentry:project:" + project) // обновляем список проектов data, _ := p.API.KVGet("sentry:projects") var projects []string if data != nil { projects = strings.Split(string(data), ",") } var newProjects []string for _, p := range projects { if p != project { newProjects = append(newProjects, p) } } p.API.KVSet("sentry:projects", []byte(strings.Join(newProjects, ","))) return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "✅ Unlinked project `" + project + "`", }, 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), ",") } if len(projects) == 0 { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: "_No linked Sentry projects_", }, nil } var lines []string for _, project := range projects { channelData, _ := p.API.KVGet("sentry:project:" + project) if channelData == nil { continue } channelID := string(channelData) channel, err := p.API.GetChannel(channelID) channelName := channelID if err == nil && channel != nil { channelName = "~" + channel.Name } lines = append(lines, "• "+project+" → "+channelName) } text := "🔗 **Linked Sentry projects:**\n\n" + strings.Join(lines, "\n") return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: text, }, nil } func (p *Plugin) commandHelp() *model.CommandResponse { return &model.CommandResponse{ ResponseType: model.CommandResponseTypeEphemeral, Text: `**Sentry plugin commands** • /sentry link — 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, }) } 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", Name: "project", Type: "text", }, }, } 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 nil, appErr } return &model.CommandResponse{}, nil }