diff --git a/cmd/app/handler/incident_handler.go b/cmd/app/handler/incident_handler.go index 43889aa..6beaedb 100644 --- a/cmd/app/handler/incident_handler.go +++ b/cmd/app/handler/incident_handler.go @@ -18,18 +18,20 @@ const ( ) type IncidentHandler struct { - gin *gin.Engine - db *gorm.DB + gin *gin.Engine + db *gorm.DB + service *incident.IncidentServiceV2 } -func NewIncidentHandler(gin *gin.Engine, db *gorm.DB) *IncidentHandler { +func NewIncidentHandler(gin *gin.Engine, db *gorm.DB, incidentService *incident.IncidentServiceV2) *IncidentHandler { return &IncidentHandler{ - gin: gin, - db: db, + gin: gin, + db: db, + service: incidentService, } } -func (h *IncidentHandler) HandleCreateIncident(c *gin.Context) { +func (handler *IncidentHandler) HandleCreateIncident(c *gin.Context) { var createIncidentRequest request.CreateIncidentRequestV2 if err := c.ShouldBindJSON(&createIncidentRequest); err != nil { c.JSON(http.StatusInternalServerError, err) @@ -41,8 +43,7 @@ func (h *IncidentHandler) HandleCreateIncident(c *gin.Context) { c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) return } - incidentServiceV2 := incident.NewIncidentServiceV2(h.db) - incidentResponse, err := incidentServiceV2.CreateIncident(createIncidentRequest, "API", "") + incidentResponse, err := handler.service.CreateIncident(createIncidentRequest, "API", "") if err != nil { logger.Error(fmt.Sprintf("%s Failed to create incident", logTag), zap.Error(err)) c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) @@ -50,3 +51,52 @@ func (h *IncidentHandler) HandleCreateIncident(c *gin.Context) { } c.JSON(http.StatusOK, common.SuccessResponse(incidentResponse, http.StatusOK)) } + +func (handler *IncidentHandler) HandleJiraLinking(c *gin.Context) { + var linkJiraRequest request.LinkJiraRequest + if err := c.ShouldBindJSON(&linkJiraRequest); err != nil { + c.JSON(http.StatusBadRequest, err) + return + } + if err := utils.ValidateLinkJiraRequest(linkJiraRequest); err != nil { + logger.Debug(fmt.Sprintf("%s invalid request to link Jira reveived. %s", logTag, err.Error())) + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadGateway, nil)) + return + } + err := handler.service.LinkJiraToIncident(linkJiraRequest.IncidentID, linkJiraRequest.User, linkJiraRequest.JiraLink) + if err != nil { + logger.Error( + fmt.Sprintf( + "%s failed to link jira to the incident %d", logTag, linkJiraRequest.IncidentID, + ), zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse("Jira link added successfully", http.StatusOK)) +} + +func (handler *IncidentHandler) HandleJiraUnLinking(c *gin.Context) { + var unlinkJiraRequest request.LinkJiraRequest + if err := c.ShouldBindJSON(&unlinkJiraRequest); err != nil { + c.JSON(http.StatusBadRequest, err) + return + } + if err := utils.ValidateLinkJiraRequest(unlinkJiraRequest); err != nil { + logger.Debug(fmt.Sprintf("%s invalid request to unLink Jira reveived. %s", logTag, err.Error())) + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadGateway, nil)) + return + } + if err := handler.service.UnLinkJiraFromIncident( + unlinkJiraRequest.IncidentID, unlinkJiraRequest.User, unlinkJiraRequest.JiraLink, + ); err != nil { + logger.Error( + fmt.Sprintf( + "%s failed to unlink jira from the incident %d", logTag, unlinkJiraRequest.IncidentID, + ), zap.Error(err), + ) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusNotFound, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse("Jira link removed successfully", http.StatusOK)) +} diff --git a/cmd/app/handler/slack_handler.go b/cmd/app/handler/slack_handler.go index dbddccd..cb1de9d 100644 --- a/cmd/app/handler/slack_handler.go +++ b/cmd/app/handler/slack_handler.go @@ -15,6 +15,8 @@ import ( "houston/model/team" "houston/model/user" "houston/pkg/slackbot" + incidentServiceV2 "houston/service/incident" + slack2 "houston/service/slack" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -48,6 +50,8 @@ func NewSlackHandler(gormClient *gorm.DB, socketModeClient *socketmode.Client) * slashCommandProcessor := processor.NewSlashCommandProcessor(socketModeClient, incidentService, slackbotClient) grafanaRepository := diagnostic.NewDiagnoseRepository(gormClient) diagnosticCommandProcessor := processor.NewDiagnosticCommandProcessor(socketModeClient, grafanaRepository) + incidentServiceV2 := incidentServiceV2.NewIncidentServiceV2(gormClient) + slackService := slack2.NewSlackService() cron.RunJob(socketModeClient, gormClient, incidentService, severityService, teamService, shedlockService, userService) @@ -59,7 +63,7 @@ func NewSlackHandler(gormClient *gorm.DB, socketModeClient *socketmode.Client) * socketModeClient, incidentService, teamService, severityService, ), blockActionProcessor: processor.NewBlockActionProcessor( - socketModeClient, incidentService, teamService, severityService, tagService, slackbotClient, + socketModeClient, incidentService, teamService, severityService, tagService, slackbotClient, incidentServiceV2, slackService, ), viewSubmissionProcessor: processor.NewViewSubmissionProcessor( socketModeClient, incidentService, teamService, severityService, tagService, teamService, slackbotClient, gormClient, diff --git a/cmd/app/server.go b/cmd/app/server.go index af5bdae..cd301ca 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -13,6 +13,7 @@ import ( "houston/model/ingester" "houston/pkg/slackbot" "houston/service" + "houston/service/incident" "net/http" "strings" "time" @@ -120,8 +121,11 @@ func (s *Server) incidentClientHandlerV2(houstonGroup *gin.RouterGroup) { origin := c.Request.Header.Get("Origin") c.Writer.Header().Set("Access-Control-Allow-Origin", origin) }) - incidentHandler := handler.NewIncidentHandler(s.gin, s.db) + incidentServiceV2 := incident.NewIncidentServiceV2(s.db) + incidentHandler := handler.NewIncidentHandler(s.gin, s.db, incidentServiceV2) houstonGroup.POST("/create-incident-v2", incidentHandler.HandleCreateIncident) + houstonGroup.POST("/link-jira-to-incident", incidentHandler.HandleJiraLinking) + houstonGroup.POST("/unlink-jira-from-incident", incidentHandler.HandleJiraUnLinking) } func (s *Server) incidentHandler(houstonGroup *gin.RouterGroup) { diff --git a/common/util/common_util.go b/common/util/common_util.go index 3bd7b06..ca662a1 100644 --- a/common/util/common_util.go +++ b/common/util/common_util.go @@ -6,6 +6,7 @@ import ( "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" "go.uber.org/zap" + "golang.org/x/exp/slices" "houston/logger" "houston/model/incident" "houston/model/severity" @@ -27,6 +28,49 @@ func RemoveDuplicate[T string | int](sliceList []T) []T { return list } +// Difference : finds difference of two slices and returns a new slice +func Difference(s1, s2 []string) []string { + combinedSlice := append(s1, s2...) + m := make(map[string]int) + for _, v := range combinedSlice { + if _, ok := m[v]; ok { + // remove element later as it exist in both slice. + m[v] += 1 + continue + } + // new entry, add in map! + m[v] = 1 + } + var result []string + for k, v := range m { + if v == 1 { + result = append(result, k) + } + } + return result +} + +func Intersection(s1, s2 []string) (inter []string) { + hash := make(map[string]bool) + for _, e := range s1 { + hash[e] = true + } + for _, e := range s2 { + // If elements present in the hashmap then append intersection list. + if hash[e] { + inter = append(inter, e) + } + } + //Remove dups from slice. + inter = RemoveDuplicate(inter) + return +} + +// Contains checks if the given string exists on the slice of string and returns boolean +func Contains[S ~[]E, E comparable](s S, v E) bool { + return slices.Contains(s, v) +} + func GetColorBySeverity(severityId uint) string { switch severityId { case 1: diff --git a/common/util/constant.go b/common/util/constant.go index fa4280e..328d117 100644 --- a/common/util/constant.go +++ b/common/util/constant.go @@ -15,6 +15,7 @@ const ( SetIncidentTitle = "set_incident_title" SetIncidentDescription = "set_incident_description" SetRCA = "set_rca" + SetJiraLinks = "set_jira_links" AddTags = "add_tags" ShowTags = "show_tags" RemoveTag = "remove_tags" @@ -33,6 +34,7 @@ const ( SetIncidentTypeSubmit = "set_incident_type_submit" UpdateTagSubmit = "updateTagSubmit" SetIncidentRcaSubmit = "set_rca_submit" + SetIncidentJiraLinksSubmit = "set_Jira_links_submit" ShowIncidentSubmit = "show_incident_submit" MarkIncidentDuplicateSubmit = "mark_incident_duplicate_submit" ) diff --git a/config/application.properties b/config/application.properties index 9aed845..fca96c1 100644 --- a/config/application.properties +++ b/config/application.properties @@ -78,4 +78,5 @@ create-incident.title.max-length=100 create-incident-v2-enabled=CREATE_INCIDENT_V2_ENABLED #slack details -slack.workspace.id=SLACK_WORKSPACE_ID \ No newline at end of file +slack.workspace.id=SLACK_WORKSPACE_ID +navi.jira.base.url=https://navihq.atlassian.net/ \ No newline at end of file diff --git a/go.mod b/go.mod index 42cd40b..c96048b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/chromedp/chromedp v0.9.1 github.com/gin-contrib/zap v0.1.0 github.com/gin-gonic/gin v1.9.1 - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.4.0 github.com/jackc/pgx/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.7 @@ -19,13 +19,14 @@ require ( github.com/spf13/viper v1.16.0 github.com/thoas/go-funk v0.9.3 go.uber.org/zap v1.24.0 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d gorm.io/datatypes v1.2.0 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 ) require ( - cloud.google.com/go/compute v1.19.0 // indirect + cloud.google.com/go/compute v1.23.1 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/aws/aws-sdk-go-v2 v1.18.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect @@ -53,9 +54,9 @@ require ( github.com/gobwas/ws v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/s2a-go v0.1.3 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect - github.com/googleapis/gax-go/v2 v2.8.0 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect @@ -63,10 +64,11 @@ require ( github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.9.0 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/oauth2 v0.7.0 // indirect + golang.org/x/oauth2 v0.13.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect - google.golang.org/grpc v1.55.0 // indirect + google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/grpc v1.59.0 // indirect gorm.io/driver/mysql v1.4.7 // indirect ) @@ -110,12 +112,12 @@ require ( go.uber.org/goleak v1.1.12 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect - google.golang.org/api v0.122.0 - google.golang.org/protobuf v1.30.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/api v0.149.0 + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/processor/action/incident_update_jira-links_action.go b/internal/processor/action/incident_update_jira-links_action.go new file mode 100644 index 0000000..40e3cd0 --- /dev/null +++ b/internal/processor/action/incident_update_jira-links_action.go @@ -0,0 +1,97 @@ +package action + +import ( + "fmt" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" + "github.com/spf13/viper" + "houston/internal/processor/action/view" + "houston/logger" + "houston/model/incident" + incidentService "houston/service/incident" + slack2 "houston/service/slack" + "strings" +) + +type IncidentUpdateJiraLinksAction struct { + client *socketmode.Client + incidentRepository *incident.Repository + incidentService *incidentService.IncidentServiceV2 + slackService *slack2.SlackService +} + +const ( + logTag = "[IncidentUpdateJiraLinksAction]" +) + +func NewIncidentUpdateJiraLinksAction( + client *socketmode.Client, + incidentRepository *incident.Repository, + incidentServiceV2 *incidentService.IncidentServiceV2, + slackService *slack2.SlackService, +) *IncidentUpdateJiraLinksAction { + return &IncidentUpdateJiraLinksAction{ + client: client, + incidentRepository: incidentRepository, + incidentService: incidentServiceV2, + slackService: slackService, + } +} + +func (action *IncidentUpdateJiraLinksAction) IncidentUpdateJiraLinksRequestProcess(callback slack.InteractionCallback, request *socketmode.Request) { + result, err := action.incidentRepository.FindIncidentByChannelId(callback.Channel.ID) + if err != nil || result == nil { + logger.Error(fmt.Sprintf("%s failed to find incident entity for: %s", logTag, callback.Channel.Name)) + return + } + + modalRequest := view.BuildJiraLinksModal(callback.Channel, result.JiraLinks...) + + _, err = action.client.OpenView(callback.TriggerID, modalRequest) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to open view command for: %s", logTag, callback.Channel.Name)) + return + } + var payload interface{} + action.client.Ack(*request, payload) +} + +func (action *IncidentUpdateJiraLinksAction) IncidentUpdateJiraLinks(callback slack.InteractionCallback, request *socketmode.Request, channel slack.Channel, user slack.User) { + channelID := callback.View.PrivateMetadata + incidentEntity, err := action.incidentRepository.FindIncidentByChannelId(channelID) + if err != nil || incidentEntity == nil { + logger.Error(fmt.Sprintf("%s failed to find incident entity for: %s", logTag, channel.Name)) + return + } + + jiraLinks := strings.Split( + strings.ReplaceAll(strings.ReplaceAll(buildUpdateJiraLinksRequest(callback.View.State.Values), "\n", ""), " ", ""), + ",", + ) + for _, l := range jiraLinks { + if strings.HasPrefix(l, viper.GetString("navi.jira.base.url")) == false { + err := action.slackService.PostEphemeralByChannelID(fmt.Sprintf("%s is not a valid jira link", l), user.ID, false, channelID) + if err != nil { + logger.Debug(fmt.Sprintf("%s failed to post jira link validation failure ephemeral to slack channel: %s", logTag, incidentEntity.IncidentName)) + } + return + } + } + err = action.incidentService.LinkJiraToIncident(incidentEntity.ID, user.ID, jiraLinks...) + + var payload interface{} + action.client.Ack(*request, payload) +} + +func buildUpdateJiraLinksRequest(blockActions map[string]map[string]slack.BlockAction) string { + var requestMap = make(map[string]string, 0) + for _, actions := range blockActions { + for actionID, a := range actions { + if a.Type == "plain_text_input" { + requestMap[actionID] = a.Value + } + } + } + + return requestMap["jira"] +} diff --git a/internal/processor/action/view/incident_jira_links.go b/internal/processor/action/view/incident_jira_links.go new file mode 100644 index 0000000..ee5bf0a --- /dev/null +++ b/internal/processor/action/view/incident_jira_links.go @@ -0,0 +1,39 @@ +package view + +import ( + "github.com/slack-go/slack" + "houston/common/util" + "strings" +) + +func BuildJiraLinksModal(channel slack.Channel, jiraLinks ...string) slack.ModalViewRequest { + titleText := slack.NewTextBlockObject(slack.PlainTextType, "Jira link(s)", false, false) + closeText := slack.NewTextBlockObject(slack.PlainTextType, "Close", false, false) + submitText := slack.NewTextBlockObject(slack.PlainTextType, "Submit", false, false) + + jiraLinksText := slack.NewTextBlockObject(slack.PlainTextType, " ", false, false) + jiraLinkPlaceholder := slack.NewTextBlockObject(slack.PlainTextType, "Add comma separated jira links here...", false, false) + jiraLinkElement := slack.NewPlainTextInputBlockElement(jiraLinkPlaceholder, "jira") + jiraLinkElement.Multiline = true + jiraLinkElement.InitialValue = strings.Join(jiraLinks, ", ") + jiraLinkElement.MaxLength = 3000 + jiraLinksBlock := slack.NewInputBlock("JIRA_LINKS", jiraLinksText, nil, jiraLinkElement) + jiraLinksBlock.Optional = false + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + jiraLinksBlock, + }, + } + + return slack.ModalViewRequest{ + Type: slack.VTModal, + Title: titleText, + Close: closeText, + Submit: submitText, + Blocks: blocks, + PrivateMetadata: channel.ID, + CallbackID: util.SetIncidentJiraLinksSubmit, + } + +} diff --git a/internal/processor/action/view/incident_section.go b/internal/processor/action/view/incident_section.go index b8ebd00..00fb073 100644 --- a/internal/processor/action/view/incident_section.go +++ b/internal/processor/action/view/incident_section.go @@ -63,11 +63,9 @@ func ExistingIncidentOptionsBlock() map[string]interface{} { return map[string]interface{}{ "blocks": []slack.Block{ incidentSectionBlock(), - taskSectionBlock(), tagsSectionBlock(), - onCallSectionBlock(), rcaSectionBlock(), - botHelpSectionBlock(), + jiraLinksSectionBlock(), }, } } @@ -331,3 +329,30 @@ func rcaSectionBlock() *slack.SectionBlock { } return slack.NewSectionBlock(textBlock, nil, accessoryOption, slack.SectionBlockOptionBlockID("set_rca")) } + +func jiraLinksSectionBlock() *slack.SectionBlock { + textBlock := &slack.TextBlockObject{ + Type: "mrkdwn", + Text: "Jira links", + } + + optionBlockObjects := []*slack.OptionBlockObject{ + { + Text: &slack.TextBlockObject{ + Type: "plain_text", + Text: "Add Jira link(s)", + }, + Value: util.SetJiraLinks, + }, + } + + accessoryOption := &slack.Accessory{ + SelectElement: &slack.SelectBlockElement{ + Type: "static_select", + ActionID: util.SetJiraLinks, + Options: optionBlockObjects, + Placeholder: slack.NewTextBlockObject("plain_text", "Select command", false, false), + }, + } + return slack.NewSectionBlock(textBlock, nil, accessoryOption, slack.SectionBlockOptionBlockID(util.SetJiraLinks)) +} diff --git a/internal/processor/event_type_interactive_processor.go b/internal/processor/event_type_interactive_processor.go index 54d8d7b..15c5198 100644 --- a/internal/processor/event_type_interactive_processor.go +++ b/internal/processor/event_type_interactive_processor.go @@ -11,6 +11,8 @@ import ( "houston/model/tag" "houston/model/team" "houston/pkg/slackbot" + incidentService "houston/service/incident" + slack2 "houston/service/slack" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" @@ -35,27 +37,49 @@ type BlockActionProcessor struct { incidentUpdateTagsAction *action.IncidentUpdateTagsAction incidentShowTagsAction *action.IncidentShowTagsAction incidentUpdateRcaAction *action.IncidentUpdateRcaAction + incidentUpdateJiraLinksAction *action.IncidentUpdateJiraLinksAction incidentDuplicateAction *action.DuplicateIncidentAction + incidentServiceV2 *incidentService.IncidentServiceV2 + slackService *slack2.SlackService } -func NewBlockActionProcessor(socketModeClient *socketmode.Client, incidentRepository *incident.Repository, - teamService *team.Repository, severityService *severity.Repository, tagService *tag.Repository, - slackbotClient *slackbot.Client) *BlockActionProcessor { +func NewBlockActionProcessor( + socketModeClient *socketmode.Client, + incidentRepository *incident.Repository, + teamService *team.Repository, + severityService *severity.Repository, + tagService *tag.Repository, + slackbotClient *slackbot.Client, + incidentServiceV2 *incidentService.IncidentServiceV2, + slackService *slack2.SlackService, +) *BlockActionProcessor { + return &BlockActionProcessor{ - socketModeClient: socketModeClient, - startIncidentBlockAction: action.NewStartIncidentBlockAction(socketModeClient, teamService, severityService), - showIncidentsAction: action.ShowIncidentsBlockAction(socketModeClient, teamService), - assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, incidentRepository), - incidentResolveAction: action.NewIncidentResolveProcessor(socketModeClient, incidentRepository, tagService, teamService, severityService), - incidentUpdateAction: action.NewIncidentUpdateAction(socketModeClient, incidentRepository, tagService, teamService, severityService), - incidentUpdateTypeAction: action.NewIncidentUpdateTypeAction(socketModeClient, incidentRepository, teamService, severityService, slackbotClient), - incidentUpdateSeverityAction: action.NewIncidentUpdateSeverityAction(socketModeClient, incidentRepository, severityService, teamService, slackbotClient), - incidentUpdateTitleAction: action.NewIncidentUpdateTitleAction(socketModeClient, incidentRepository, teamService, severityService, slackbotClient), + socketModeClient: socketModeClient, + startIncidentBlockAction: action.NewStartIncidentBlockAction(socketModeClient, teamService, + severityService), + showIncidentsAction: action.ShowIncidentsBlockAction(socketModeClient, teamService), + assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, incidentRepository), + incidentResolveAction: action.NewIncidentResolveProcessor(socketModeClient, incidentRepository, + tagService, teamService, severityService), + incidentUpdateAction: action.NewIncidentUpdateAction(socketModeClient, incidentRepository, + tagService, teamService, severityService), + incidentUpdateTypeAction: action.NewIncidentUpdateTypeAction(socketModeClient, incidentRepository, + teamService, severityService, slackbotClient), + incidentUpdateSeverityAction: action.NewIncidentUpdateSeverityAction(socketModeClient, incidentRepository, + severityService, teamService, slackbotClient), + incidentUpdateTitleAction: action.NewIncidentUpdateTitleAction(socketModeClient, incidentRepository, + teamService, severityService, slackbotClient), incidentUpdateDescriptionAction: action.NewIncidentUpdateDescriptionAction(socketModeClient, incidentRepository), - incidentUpdateTagsAction: action.NewIncidentUpdateTagsAction(socketModeClient, incidentRepository, teamService, tagService), - incidentShowTagsAction: action.NewIncidentShowTagsProcessor(socketModeClient, incidentRepository, tagService), - incidentUpdateRcaAction: action.NewIncidentUpdateRcaAction(socketModeClient, incidentRepository), - incidentDuplicateAction: action.NewDuplicateIncidentProcessor(socketModeClient, incidentRepository, tagService, teamService, severityService), + incidentUpdateTagsAction: action.NewIncidentUpdateTagsAction(socketModeClient, incidentRepository, + teamService, tagService), + incidentShowTagsAction: action.NewIncidentShowTagsProcessor(socketModeClient, incidentRepository, + tagService), + incidentUpdateRcaAction: action.NewIncidentUpdateRcaAction(socketModeClient, incidentRepository), + incidentUpdateJiraLinksAction: action.NewIncidentUpdateJiraLinksAction(socketModeClient, incidentRepository, + incidentServiceV2, slackService), + incidentDuplicateAction: action.NewDuplicateIncidentProcessor(socketModeClient, incidentRepository, + tagService, teamService, severityService), } } @@ -95,6 +119,10 @@ func (bap *BlockActionProcessor) ProcessCommand(callback slack.InteractionCallba { bap.processRcaCommands(callback, request) } + case util.SetJiraLinks: + { + bap.processJiraLinkCommands(callback, request) + } default: { msgOption := slack.MsgOptionText(fmt.Sprintf("We are working on it"), false) @@ -159,6 +187,16 @@ func (bap *BlockActionProcessor) processRcaCommands(callback slack.InteractionCa } } +func (bap *BlockActionProcessor) processJiraLinkCommands(callback slack.InteractionCallback, request *socketmode.Request) { + action1 := util.BlockActionType(callback.ActionCallback.BlockActions[0].SelectedOption.Value) + switch action1 { + case util.SetJiraLinks: + { + bap.incidentUpdateJiraLinksAction.IncidentUpdateJiraLinksRequestProcess(callback, request) + } + } +} + func (bap *BlockActionProcessor) processTagsCommands(callback slack.InteractionCallback, request *socketmode.Request) { action1 := util.BlockActionType(callback.ActionCallback.BlockActions[0].SelectedOption.Value) switch action1 { @@ -189,6 +227,7 @@ type ViewSubmissionProcessor struct { incidentUpdateTypeAction *action.IncidentUpdateTypeAction incidentUpdateTagsAction *action.IncidentUpdateTagsAction incidentUpdateRca *action.IncidentUpdateRcaAction + incidentUpdateJiraLinks *action.IncidentUpdateJiraLinksAction showIncidentSubmitAction *action.ShowIncidentsSubmitAction incidentDuplicateAction *action.DuplicateIncidentAction db *gorm.DB @@ -204,20 +243,33 @@ func NewViewSubmissionProcessor( slackbotClient *slackbot.Client, db *gorm.DB, ) *ViewSubmissionProcessor { + incidentServiceV2 := incidentService.NewIncidentServiceV2(db) + slackService := slack2.NewSlackService() return &ViewSubmissionProcessor{ - socketModeClient: socketModeClient, - incidentChannelMessageUpdateAction: action.NewIncidentChannelMessageUpdateAction(socketModeClient, incidentRepository, teamService, severityService), - createIncidentAction: action.NewCreateIncidentProcessor(socketModeClient, incidentRepository, teamService, severityService, slackbotClient, db), - assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, incidentRepository), - updateIncidentAction: action.NewIncidentUpdateAction(socketModeClient, incidentRepository, tagService, teamService, severityService), - incidentUpdateTitleAction: action.NewIncidentUpdateTitleAction(socketModeClient, incidentRepository, teamService, severityService, slackbotClient), - incidentUpdateDescriptionAction: action.NewIncidentUpdateDescriptionAction(socketModeClient, incidentRepository), - incidentUpdateSeverityAction: action.NewIncidentUpdateSeverityAction(socketModeClient, incidentRepository, severityService, teamService, slackbotClient), - incidentUpdateTypeAction: action.NewIncidentUpdateTypeAction(socketModeClient, incidentRepository, teamService, severityService, slackbotClient), - incidentUpdateTagsAction: action.NewIncidentUpdateTagsAction(socketModeClient, incidentRepository, teamService, tagService), - incidentUpdateRca: action.NewIncidentUpdateRcaAction(socketModeClient, incidentRepository), - showIncidentSubmitAction: action.NewShowIncidentsSubmitAction(socketModeClient, incidentRepository, teamRepository), - incidentDuplicateAction: action.NewDuplicateIncidentProcessor(socketModeClient, incidentRepository, tagService, teamRepository, severityService), + socketModeClient: socketModeClient, + incidentChannelMessageUpdateAction: action.NewIncidentChannelMessageUpdateAction(socketModeClient, + incidentRepository, teamService, severityService), + createIncidentAction: action.NewCreateIncidentProcessor(socketModeClient, incidentRepository, + teamService, severityService, slackbotClient, db), + assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, incidentRepository), + updateIncidentAction: action.NewIncidentUpdateAction(socketModeClient, incidentRepository, + tagService, teamService, severityService), + incidentUpdateTitleAction: action.NewIncidentUpdateTitleAction(socketModeClient, incidentRepository, + teamService, severityService, slackbotClient), + incidentUpdateDescriptionAction: action.NewIncidentUpdateDescriptionAction(socketModeClient, incidentRepository), + incidentUpdateSeverityAction: action.NewIncidentUpdateSeverityAction(socketModeClient, incidentRepository, + severityService, teamService, slackbotClient), + incidentUpdateTypeAction: action.NewIncidentUpdateTypeAction(socketModeClient, incidentRepository, + teamService, severityService, slackbotClient), + incidentUpdateTagsAction: action.NewIncidentUpdateTagsAction(socketModeClient, incidentRepository, + teamService, tagService), + incidentUpdateRca: action.NewIncidentUpdateRcaAction(socketModeClient, incidentRepository), + incidentUpdateJiraLinks: action.NewIncidentUpdateJiraLinksAction(socketModeClient, incidentRepository, + incidentServiceV2, slackService), + showIncidentSubmitAction: action.NewShowIncidentsSubmitAction(socketModeClient, incidentRepository, + teamRepository), + incidentDuplicateAction: action.NewDuplicateIncidentProcessor(socketModeClient, incidentRepository, + tagService, teamRepository, severityService), } } @@ -271,6 +323,10 @@ func (vsp *ViewSubmissionProcessor) ProcessCommand(callback slack.InteractionCal { vsp.incidentUpdateRca.IncidentUpdateRca(callback, request, callback.Channel, callback.User) } + case util.SetIncidentJiraLinksSubmit: + { + vsp.incidentUpdateJiraLinks.IncidentUpdateJiraLinks(callback, request, callback.Channel, callback.User) + } case util.ShowIncidentSubmit: { vsp.showIncidentSubmitAction.ShowIncidentsCommandProcessing(callback, request) diff --git a/model/incident/entity.go b/model/incident/entity.go index 49b4162..38e7c9a 100644 --- a/model/incident/entity.go +++ b/model/incident/entity.go @@ -44,25 +44,25 @@ const ( // IncidentEntity all the incident created will go in this table type IncidentEntity struct { gorm.Model - Title string `gorm:"column:title"` - Description string `gorm:"column:description"` - Status uint `gorm:"column:status"` - SeverityId uint `gorm:"column:severity_id"` - IncidentName string `gorm:"column:incident_name"` - SlackChannel string `gorm:"column:slack_channel"` - DetectionTime *time.Time `gorm:"column:detection_time"` - StartTime time.Time `gorm:"column:start_time"` - EndTime *time.Time `gorm:"column:end_time"` - TeamId uint `gorm:"column:team_id"` - JiraId *string `gorm:"column:jira_id"` - ConfluenceId *string `gorm:"column:confluence_id"` - SeverityTat time.Time `gorm:"column:severity_tat"` - RemindMeAt *time.Time `gorm:"column:remind_me_at"` - EnableReminder bool `gorm:"column:enable_reminder"` - CreatedBy string `gorm:"column:created_by"` - UpdatedBy string `gorm:"column:updated_by"` - MetaData JSON `gorm:"column:meta_data"` - RCA string `gorm:"column:rca_text"` + Title string `gorm:"column:title"` + Description string `gorm:"column:description"` + Status uint `gorm:"column:status"` + SeverityId uint `gorm:"column:severity_id"` + IncidentName string `gorm:"column:incident_name"` + SlackChannel string `gorm:"column:slack_channel"` + DetectionTime *time.Time `gorm:"column:detection_time"` + StartTime time.Time `gorm:"column:start_time"` + EndTime *time.Time `gorm:"column:end_time"` + TeamId uint `gorm:"column:team_id"` + JiraLinks pq.StringArray `gorm:"column:jira_links;type:string[]"` + ConfluenceId *string `gorm:"column:confluence_id"` + SeverityTat time.Time `gorm:"column:severity_tat"` + RemindMeAt *time.Time `gorm:"column:remind_me_at"` + EnableReminder bool `gorm:"column:enable_reminder"` + CreatedBy string `gorm:"column:created_by"` + UpdatedBy string `gorm:"column:updated_by"` + MetaData JSON `gorm:"column:meta_data"` + RCA string `gorm:"column:rca_text"` } func (IncidentEntity) TableName() string { diff --git a/model/incident/incident.go b/model/incident/incident.go index 2b8f457..1842801 100644 --- a/model/incident/incident.go +++ b/model/incident/incident.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/slack-go/slack/socketmode" "github.com/spf13/viper" - logger "houston/logger" + "houston/logger" "houston/model/log" "houston/model/severity" "houston/model/team" @@ -297,7 +297,7 @@ func (r *Repository) FindIncidentById(Id uint) (*IncidentEntity, error) { } if result.RowsAffected == 0 { - return nil, nil + return nil, fmt.Errorf("could not find incident with ID: %d", Id) } return &incidentEntity, nil diff --git a/service/incident/incident_service_v2.go b/service/incident/incident_service_v2.go index f1a55a4..edbf43c 100644 --- a/service/incident/incident_service_v2.go +++ b/service/incident/incident_service_v2.go @@ -3,6 +3,7 @@ package incident import ( "context" "encoding/json" + "errors" "fmt" "github.com/google/uuid" slackClient "github.com/slack-go/slack" @@ -22,6 +23,7 @@ import ( request "houston/service/request" service "houston/service/response" "houston/service/slack" + "regexp" "strconv" "strings" "time" @@ -150,6 +152,125 @@ func (i *IncidentServiceV2) CreateIncident( return service.ConvertToIncidentResponse(*incidentEntity), nil } +func (i *IncidentServiceV2) LinkJiraToIncident(incidentId uint, linkedBy string, jiraLinks ...string) error { + logger.Info(fmt.Sprintf("%s received request to link jira to %d", logTag, incidentId)) + logTag := fmt.Sprintf("%s [LinkJiraToIncident]", logTag) + entity, err := i.incidentRepository.FindIncidentById(incidentId) + if err != nil { + errorMessage := fmt.Sprintf("%s failed to fetch indient by id %d", logTag, incidentId) + logger.Info(errorMessage) + return fmt.Errorf("failed to fetch incident by id %d", incidentId) + } + if len(jiraLinks) == 1 { + if util.Contains(entity.JiraLinks, jiraLinks[0]) { + return errors.New("The jira link already exists") + } + } + err = i.updateJiraIDs(entity, linkedBy, logTag, LinkJira, jiraLinks...) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to link jira to the incident: %s", logTag, entity.IncidentName)) + _, err := i.slackService.PostMessageByChannelID("failed to link Jira", false, entity.SlackChannel) + if err != nil { + logger.Info(fmt.Sprintf("%s failed to post jira linking failure message to slack channel: %s", logTag, entity.IncidentName)) + } + } + return nil +} + +func (i *IncidentServiceV2) UnLinkJiraFromIncident(incidentId uint, unLinkedBy, jiraLink string) error { + logger.Info(fmt.Sprintf("%s received request to unLink jira from %d", logTag, incidentId)) + logTag := fmt.Sprintf("%s [UnLinkJiraFromIncident]", logTag) + entity, err := i.incidentRepository.FindIncidentById(incidentId) + if err != nil { + logger.Info(fmt.Sprintf("%s failed to fetch indient by id %d", logTag, incidentId)) + return fmt.Errorf("failed to fetch indient by id %d", incidentId) + } + err = i.updateJiraIDs(entity, unLinkedBy, logTag, UnLinkJira, jiraLink) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to unlink Jira ID(s): %s from incident %s", logTag, jiraLink, entity.IncidentName)) + return err + } + return nil +} + +const ( + LinkJira string = "link" + UnLinkJira = "unLink" +) + +func (i *IncidentServiceV2) updateJiraIDs(entity *incident.IncidentEntity, user, logTag, action string, jiraLinks ...string) error { + slackUser, err := i.slackService.GetUserByEmailOrID(user) + var updatedBy string + re := regexp.MustCompile(`\n+`) + if err != nil { + logger.Info(fmt.Sprintf("%s failed to find user in slack for given input %s", logTag, user)) + updatedBy = user + } else { + updatedBy = slackUser.ID + } + var jiraToBeUpdated []string + var slackMessage string + if action == LinkJira { + jiraToBeUpdated = util.RemoveDuplicateStr(append(entity.JiraLinks, jiraLinks...)) + var newJiraLinks []string + for _, i := range jiraLinks { + if !util.Contains(entity.JiraLinks, i) { + newJiraLinks = append(newJiraLinks, i) + } + } + if len(newJiraLinks) == 0 { + _ = i.slackService.PostEphemeralByChannelID("`No new jira link to add`", updatedBy, false, entity.SlackChannel) + } else if len(newJiraLinks) > 1 { + slackMessage = fmt.Sprintf( + "<@%s> `linked the following jiras to the incident: \"%s\"`", + updatedBy, re.ReplaceAllString(strings.Join(newJiraLinks, ", "), " "), + ) + } else { + slackMessage = fmt.Sprintf( + "<@%s> `linked the following jira to the incident: \"%s\"`", + updatedBy, re.ReplaceAllString(strings.Join(newJiraLinks, ", "), " "), + ) + } + } else { + var validatedJiraIDsToBeRemoved []string + for _, j := range jiraLinks { + if util.Contains(entity.JiraLinks, j) { + validatedJiraIDsToBeRemoved = append(validatedJiraIDsToBeRemoved, j) + } + } + jiraToBeUpdated = util.Difference(entity.JiraLinks, validatedJiraIDsToBeRemoved) + if len(validatedJiraIDsToBeRemoved) > 0 { + slackMessage = fmt.Sprintf( + "<@%s> `unlinked the following jira from the incident: \"%s\"`", + updatedBy, re.ReplaceAllString(strings.Join(validatedJiraIDsToBeRemoved, ", "), " "), + ) + } + } + if jiraToBeUpdated == nil { + jiraToBeUpdated = make([]string, 0) + } + + entity.JiraLinks = jiraToBeUpdated + entity.UpdatedBy = updatedBy + entity.UpdatedAt = time.Now() + + err = i.incidentRepository.UpdateIncident(entity) + if err != nil { + errorMessage := fmt.Sprintf("%s failed to update Jira IDs for incident %s", logTag, entity.IncidentName) + logger.Error(errorMessage) + return fmt.Errorf(errorMessage, err) + } + + if slackMessage != "" { + _, err = i.slackService.PostMessageByChannelID(slackMessage, false, entity.SlackChannel) + if err != nil { + logger.Error(fmt.Sprintf("%s failed to post message about update jira link to incident for %s", logTag, entity.IncidentName)) + } + } + + return nil +} + func createIncidentWorkflow( i *IncidentServiceV2, channel *slackClient.Channel, @@ -540,7 +661,7 @@ func tagPseOrDevOncallToIncident( //oncall handles should already be added to channel ts, err := i.slackService.PostMessage(fmt.Sprintf("<@%s>", onCallToBeTagged), false, channel) if err != nil { - logger.Debug( + logger.Info( fmt.Sprintf("%s [%s] failed to tag oncall in channel: %s", logTag, incidentName, channel.Name), zap.Error(err), ) diff --git a/service/request/link_jira_request.go b/service/request/link_jira_request.go new file mode 100644 index 0000000..cb32ef9 --- /dev/null +++ b/service/request/link_jira_request.go @@ -0,0 +1,7 @@ +package service + +type LinkJiraRequest struct { + IncidentID uint `json:"incident_id"` + JiraLink string `json:"jira_link"` + User string `json:"user"` +} diff --git a/service/response/incident_response.go b/service/response/incident_response.go index 4741a59..73fdfca 100644 --- a/service/response/incident_response.go +++ b/service/response/incident_response.go @@ -1,34 +1,35 @@ package service import ( + "github.com/lib/pq" "houston/model/incident" "time" ) type IncidentResponse struct { - ID uint `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Status uint `json:"status"` - StatusName string `json:"statusName"` - SeverityId uint `json:"severityId"` - SeverityName string `json:"severityName"` - IncidentName string `json:"incidentName"` - SlackChannel string `json:"slackChannel"` - DetectionTime *time.Time `json:"detectionTime"` - StartTime time.Time `json:"startTime"` - EndTime *time.Time `json:"endTime"` - TeamId uint `json:"teamId"` - TeamName string `json:"teamName"` - JiraId *string `json:"jiraId"` - ConfluenceId *string `json:"confluenceId"` - SeverityTat time.Time `json:"severityTat"` - RemindMeAt *time.Time `json:"remindMeAt"` - EnableReminder bool `json:"enableReminder"` - CreatedBy string `json:"createdBy"` - UpdatedBy string `json:"updatedBy"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status uint `json:"status"` + StatusName string `json:"statusName"` + SeverityId uint `json:"severityId"` + SeverityName string `json:"severityName"` + IncidentName string `json:"incidentName"` + SlackChannel string `json:"slackChannel"` + DetectionTime *time.Time `json:"detectionTime"` + StartTime time.Time `json:"startTime"` + EndTime *time.Time `json:"endTime"` + TeamId uint `json:"teamId"` + TeamName string `json:"teamName"` + JiraLinks pq.StringArray `json:"jiraLinks"` + ConfluenceId *string `json:"confluenceId"` + SeverityTat time.Time `json:"severityTat"` + RemindMeAt *time.Time `json:"remindMeAt"` + EnableReminder bool `json:"enableReminder"` + CreatedBy string `json:"createdBy"` + UpdatedBy string `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func ConvertToIncidentResponse(incidentEntity incident.IncidentEntity) IncidentResponse { @@ -44,7 +45,7 @@ func ConvertToIncidentResponse(incidentEntity incident.IncidentEntity) IncidentR DetectionTime: incidentEntity.DetectionTime, StartTime: incidentEntity.StartTime, EndTime: incidentEntity.EndTime, - JiraId: incidentEntity.JiraId, + JiraLinks: incidentEntity.JiraLinks, ConfluenceId: incidentEntity.ConfluenceId, SeverityTat: incidentEntity.SeverityTat, RemindMeAt: incidentEntity.RemindMeAt, diff --git a/service/slack/slack_service.go b/service/slack/slack_service.go index ae9907c..83ecdc3 100644 --- a/service/slack/slack_service.go +++ b/service/slack/slack_service.go @@ -58,6 +58,17 @@ func (s *SlackService) PostMessageByChannelID(message string, escape bool, chann return timeStamp, nil } +func (s *SlackService) PostEphemeralByChannelID(message string, userID string, escape bool, channelID string) error { + msgOption := slack.MsgOptionText(message, escape) + _, err := s.SocketModeClient.PostEphemeral(channelID, userID, msgOption) + if err != nil { + e := fmt.Sprintf("%s Failed to post message into channel: %s", logTag, channelID) + logger.Error(e, zap.Error(err)) + return fmt.Errorf("%s : %+v", e, err) + } + return nil +} + func (s *SlackService) PostMessageBlocks(channelID string, blocks slack.Blocks, color string) (string, error) { att := slack.Attachment{Blocks: blocks, Color: color} messageOption := slack.MsgOptionAttachments(att) @@ -105,10 +116,13 @@ func (s *SlackService) GetUserByEmailOrID(userEmailOrSlackID string) (*slack.Use slackUser, err = s.GetUserBySlackID(userEmailOrSlackID) if err != nil { slackUser, err = s.GetUserByEmail(userEmailOrSlackID) + if err != nil { + return nil, fmt.Errorf( + "failed to find slack user by email or slack for %s. Error: %v", userEmailOrSlackID, err, + ) + } } - return slackUser, fmt.Errorf( - "failed to find slack user by email or slack for %s. Error: %v", userEmailOrSlackID, err, - ) + return slackUser, nil } func (s *SlackService) GetUserByEmail(userEmail string) (*slack.User, error) { diff --git a/service/utils/validations.go b/service/utils/validations.go index 36c55d0..9c9d27d 100644 --- a/service/utils/validations.go +++ b/service/utils/validations.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/viper" service "houston/service/request" "strconv" + "strings" ) func ValidatePage(pageSize, pageNumber string) (int64, int64, error) { @@ -82,6 +83,26 @@ func ValidateCreateIncidentRequestV2(request service.CreateIncidentRequestV2) er return nil } +func ValidateLinkJiraRequest(request service.LinkJiraRequest) error { + var errorMessage []string + if request.IncidentID == 0 { + errorMessage = append(errorMessage, "Enter a valid incident I.D.") + } + if request.JiraLink == "" { + errorMessage = append(errorMessage, "Jira links can not be empty") + } + if strings.HasPrefix(request.JiraLink, viper.GetString("navi.jira.base.url")) == false { + errorMessage = append(errorMessage, fmt.Sprintf("%s is not a valid Jira link", request.JiraLink)) + } + if request.User == "" { + errorMessage = append(errorMessage, "Enter a valid navi email or slack ID for the linked_by field") + } + if errorMessage != nil { + return fmt.Errorf(strings.Join(errorMessage, ". ")) + } + return nil +} + func ValidateUpdateTeamRequest(request service.UpdateTeamRequest) error { if request.Id == 0 { return errors.New("id should be present in update request")