From 1f97532f50fef98b5a7b16f74fcb346ac34c673b Mon Sep 17 00:00:00 2001 From: Shashank Shekhar Date: Mon, 9 Oct 2023 16:04:04 +0530 Subject: [PATCH] Release 1 (#209) * TP-43474 | Fixing time difference calculation issue (#193) * TP-43298 | Adding SLA messagae to incident channel below incident summary (#183) * TP-43298 | Resolving merge conflicts * TP-43298 | Updating serverity check condition * TP-43625 | Fixing user id check (#201) * TP-43625 | Fixing user id check * TP-43625 | Fixing user id check * Incident service v2 (#191) * TP-43339 | create-incident-v2 * TP-43339 | create-incident-v2 handler * TP-43339 | added logs and some optimiations * TP-43339 | added slack processor v2 * TP-43339 | create-incident-v2 integration with slack * TP-43339 | adding feature flag for create-incident-v2 * TP-43339 | removed redundant entity fetch * TP-43339 | Added SLA related changes to create-incident-v2 * TP-43339 | Posting incident summary to blazeGroup channel and improved logs * TP-43339 | Moving post summary method to the incident service file * TP-43339 | Removed socketModeClient usage from incident-service-v2, moved it to slack-service * TP-43339 | Updated env variable for create-incident-v2 --------- Co-authored-by: Sriram Bhargav --- cmd/app/handler/incident_handler.go | 53 ++ cmd/app/handler/slack_handler.go | 2 +- cmd/app/server.go | 13 + cmd/main.go | 11 +- common/util/common_util.go | 48 ++ common/util/datetime_util.go | 11 + common/util/json_util.go | 18 + config/application.properties | 4 +- go.mod | 3 - .../incident_channel_message_update_action.go | 9 +- .../start_incident_modal_submission_action.go | 102 ++- .../event_type_interactive_processor.go | 26 +- model/incident/incident.go | 2 +- model/incident/model.go | 2 +- service/auth_service.go | 24 + service/incident/incident_service_v2.go | 628 ++++++++++++++++++ service/incident_service.go | 20 +- service/request/create_incident.go | 9 + service/slack/slack_service.go | 205 ++++++ service/team_service.go | 2 +- service/utils/validations.go | 18 + 21 files changed, 1173 insertions(+), 37 deletions(-) create mode 100644 cmd/app/handler/incident_handler.go create mode 100644 common/util/datetime_util.go create mode 100644 common/util/json_util.go create mode 100644 service/incident/incident_service_v2.go create mode 100644 service/slack/slack_service.go diff --git a/cmd/app/handler/incident_handler.go b/cmd/app/handler/incident_handler.go new file mode 100644 index 0000000..af1b660 --- /dev/null +++ b/cmd/app/handler/incident_handler.go @@ -0,0 +1,53 @@ +package handler + +import ( + "fmt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" + "houston/service/incident" + request "houston/service/request" + common "houston/service/response/common" + utils "houston/service/utils" + "net/http" +) + +const ( + logTag = "[incident_handler]" +) + +type IncidentHandler struct { + gin *gin.Engine + logger *zap.Logger + db *gorm.DB +} + +func NewIncidentHandler(gin *gin.Engine, logger *zap.Logger, db *gorm.DB) *IncidentHandler { + return &IncidentHandler{ + gin: gin, + logger: logger, + db: db, + } +} + +func (h *IncidentHandler) HandleCreateIncident(c *gin.Context) { + var createIncidentRequest request.CreateIncidentRequestV2 + if err := c.ShouldBindJSON(&createIncidentRequest); err != nil { + c.JSON(http.StatusInternalServerError, err) + return + } + + if err := utils.ValidateCreateIncidentRequestV2(createIncidentRequest); err != nil { + h.logger.Error(fmt.Sprintf("%s Received invalid request to create new incident", logTag), zap.Error(err)) + c.JSON(http.StatusBadRequest, common.ErrorResponse(err, http.StatusBadRequest, nil)) + return + } + incidentServiceV2 := incident.NewIncidentServiceV2(h.logger, h.db) + incidentResponse, err := incidentServiceV2.CreateIncident(createIncidentRequest, "API", "") + if err != nil { + h.logger.Error(fmt.Sprintf("%s Failed to create incident", logTag), zap.Error(err)) + c.JSON(http.StatusInternalServerError, common.ErrorResponse(err, http.StatusInternalServerError, nil)) + return + } + c.JSON(http.StatusOK, common.SuccessResponse(incidentResponse, http.StatusOK)) +} diff --git a/cmd/app/handler/slack_handler.go b/cmd/app/handler/slack_handler.go index 710d968..4633e2a 100644 --- a/cmd/app/handler/slack_handler.go +++ b/cmd/app/handler/slack_handler.go @@ -59,7 +59,7 @@ func NewSlackHandler(logger *zap.Logger, gormClient *gorm.DB, socketModeClient * logger, socketModeClient, incidentService, teamService, severityService, tagService, slackbotClient, ), viewSubmissionProcessor: processor.NewViewSubmissionProcessor( - logger, socketModeClient, incidentService, teamService, severityService, tagService, teamService, slackbotClient, + logger, socketModeClient, incidentService, teamService, severityService, tagService, teamService, slackbotClient, gormClient, ), slashCommandResolver: resolver.NewSlashCommandResolver( logger, diagnosticCommandProcessor, slashCommandProcessor, diff --git a/cmd/app/server.go b/cmd/app/server.go index 52cc84b..aa96bbb 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -36,6 +36,7 @@ func NewServer(gin *gin.Engine, logger *zap.Logger, db *gorm.DB, mjolnirClient * func (s *Server) Handler(houstonGroup *gin.RouterGroup) { s.readinessHandler(houstonGroup) s.incidentClientHandler(houstonGroup) + s.incidentClientHandlerV2(houstonGroup) s.filtersHandlerV2(houstonGroup) s.gin.Use(s.createMiddleware()) s.teamHandler(houstonGroup) @@ -110,6 +111,18 @@ func (s *Server) incidentClientHandler(houstonGroup *gin.RouterGroup) { houstonGroup.POST("/create-incident", incidentHandler.CreateIncident) } +func (s *Server) incidentClientHandlerV2(houstonGroup *gin.RouterGroup) { + houstonGroup.Use(func(c *gin.Context) { + // Add your desired header key-value pair + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") + c.Writer.Header().Set("Access-Control-Allow-Headers", viper.GetString("allowed.custom.headers")) + origin := c.Request.Header.Get("Origin") + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + }) + incidentHandler := handler.NewIncidentHandler(s.gin, s.logger, s.db) + houstonGroup.POST("/create-incident-v2", incidentHandler.HandleCreateIncident) +} + func (s *Server) incidentHandler(houstonGroup *gin.RouterGroup) { houstonClient := NewHoustonClient(s.logger) incidentHandler := service.NewIncidentService(s.gin, s.logger, s.db, houstonClient.socketModeClient) diff --git a/cmd/main.go b/cmd/main.go index 18e248a..3aeb09b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,9 +34,14 @@ func main() { r.Use(ginzap.RecoveryWithZap(logger, true)) houston := r.Group("/houston") - db := postgres.NewGormClient(viper.GetString("postgres.dsn"), viper.GetString("postgres.connection.max.idle.time"), - viper.GetString("postgres.connection.max.lifetime"), viper.GetInt("postgres.connections.max.idle"), - viper.GetInt("postgres.connections.max.open"), logger) + db := postgres.NewGormClient( + viper.GetString("postgres.dsn"), + viper.GetString("postgres.connection.max.idle.time"), + viper.GetString("postgres.connection.max.lifetime"), + viper.GetInt("postgres.connections.max.idle"), + viper.GetInt("postgres.connections.max.open"), + logger, + ) httpClient := clients.NewHttpClient() mjolnirClient := clients.NewMjolnirClient(httpClient.HttpClient, logger, viper.GetString("mjolnir.service.url"), viper.GetString("mjolnir.realm.id"), diff --git a/common/util/common_util.go b/common/util/common_util.go index 0bfa4dc..b9796e8 100644 --- a/common/util/common_util.go +++ b/common/util/common_util.go @@ -5,7 +5,13 @@ import ( "fmt" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" + "go.uber.org/zap" + "houston/model/incident" + "houston/model/severity" + "houston/model/team" request "houston/service/request" + "math" + "time" ) func GetColorBySeverity(severityId uint) string { @@ -79,3 +85,45 @@ func RemoveDuplicateStr(strSlice []string) []string { } return list } + +func PostIncidentSeverityEscalationHeadsUpMessage(severities *[]severity.SeverityEntity, incidentEntity *incident.IncidentEntity, teamEntity *team.TeamEntity, client *socketmode.Client) { + fromSeverityName := getSeverityById(severities, incidentEntity.SeverityId) + toSeverityName := getSeverityById(severities, incidentEntity.SeverityId-1) + daysToEscalation := calculateDifferenceInDays(incidentEntity.SeverityTat, time.Now()) + teamSlackChannel := teamEntity.WebhookSlackChannel + if daysToEscalation != 0 { + msgOption := slack.MsgOptionText(fmt.Sprintf("This incident will be auto-escalated to `%v` in `%v day(s)`", toSeverityName, daysToEscalation), false) + _, _, err := client.PostMessage(incidentEntity.SlackChannel, msgOption) + if err != nil { + logger.Info(fmt.Sprintf("Error posting message to incident channel for incident %v", incidentEntity.IncidentName), zap.Error(err)) + return + } + if teamSlackChannel != "" { + msgOption := slack.MsgOptionText(fmt.Sprintf("<#%s> (`%v`) will be auto-escalated to `%v` in `%v day(s)`", incidentEntity.SlackChannel, fromSeverityName, toSeverityName, daysToEscalation), false) + _, _, err := client.PostMessage(teamSlackChannel, msgOption) + if err != nil { + logger.Info(fmt.Sprintf("Error posting message to team channel for incident %v", incidentEntity.IncidentName), zap.Error(err)) + return + } + } + } +} +func getSeverityById(severities *[]severity.SeverityEntity, severityId uint) string { + for _, severityEntity := range *severities { + if severityEntity.ID == severityId { + return severityEntity.Name + } + } + return "" +} + +func calculateDifferenceInDays(fromTime, toTime time.Time) int { + fromDate := time.Date(fromTime.Year(), fromTime.Month(), fromTime.Day(), 0, 0, 0, 0, time.UTC) + toDate := time.Date(toTime.Year(), toTime.Month(), toTime.Day(), 0, 0, 0, 0, time.UTC) + return int(math.Abs(toDate.Sub(fromDate).Hours() / 24)) +} +func PostMessageToIncidentChannel(message string, channelId string, client *socketmode.Client) error { + msgOption := slack.MsgOptionText(message, false) + _, _, errMessage := client.PostMessage(channelId, msgOption) + return errMessage +} diff --git a/common/util/datetime_util.go b/common/util/datetime_util.go new file mode 100644 index 0000000..02c65af --- /dev/null +++ b/common/util/datetime_util.go @@ -0,0 +1,11 @@ +package util + +import "time" + +func GetCurrentTime() int64 { + timeInIst, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + return 0 + } + return time.Now().In(timeInIst).UnixNano() / 1e6 +} diff --git a/common/util/json_util.go b/common/util/json_util.go new file mode 100644 index 0000000..735226b --- /dev/null +++ b/common/util/json_util.go @@ -0,0 +1,18 @@ +package util + +import ( + "encoding/json" +) + +func JsonToStruct[JsonObject any, Struct any](obj JsonObject, result *Struct) error { + messageBytes, err := json.Marshal(obj) + if err != nil { + return err + } + + err = json.Unmarshal(messageBytes, &result) + if err != nil { + return err + } + return nil +} diff --git a/config/application.properties b/config/application.properties index e0554c2..2cd1614 100644 --- a/config/application.properties +++ b/config/application.properties @@ -69,4 +69,6 @@ grafana.token=GRAFANA_TOKEN config.sa.keys=CONFIG_SA_KEYS create-incident.description.max-length=500 -create-incident.title.max-length=100 \ No newline at end of file +create-incident.title.max-length=100 + +create-incident-v2-enabled=CREATE_INCIDENT_V2_ENABLED \ No newline at end of file diff --git a/go.mod b/go.mod index 0fcf2cf..9ca9764 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/spf13/viper v1.16.0 github.com/thoas/go-funk v0.9.3 go.uber.org/zap v1.24.0 - gorm.io/datatypes v1.2.0 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 ) @@ -46,7 +45,6 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.1.0 // indirect @@ -66,7 +64,6 @@ require ( 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 - gorm.io/driver/mysql v1.4.7 // indirect ) require ( diff --git a/internal/processor/action/incident_channel_message_update_action.go b/internal/processor/action/incident_channel_message_update_action.go index b262dad..f121637 100644 --- a/internal/processor/action/incident_channel_message_update_action.go +++ b/internal/processor/action/incident_channel_message_update_action.go @@ -22,8 +22,13 @@ type IncidentChannelMessageUpdateAction struct { severityService *severity.Repository } -func NewIncidentChannelMessageUpdateAction(socketModeClient *socketmode.Client, logger *zap.Logger, - incidentService *incident.Repository, teamService *team.Repository, severityService *severity.Repository) *IncidentChannelMessageUpdateAction { +func NewIncidentChannelMessageUpdateAction( + socketModeClient *socketmode.Client, + logger *zap.Logger, + incidentService *incident.Repository, + teamService *team.Repository, + severityService *severity.Repository, +) *IncidentChannelMessageUpdateAction { return &IncidentChannelMessageUpdateAction{ socketModeClient: socketModeClient, logger: logger, diff --git a/internal/processor/action/start_incident_modal_submission_action.go b/internal/processor/action/start_incident_modal_submission_action.go index c35f5ff..2119d1a 100644 --- a/internal/processor/action/start_incident_modal_submission_action.go +++ b/internal/processor/action/start_incident_modal_submission_action.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "fmt" + "gorm.io/gorm" "houston/common/util" "houston/internal/processor/action/view" "houston/model/incident" "houston/model/severity" "houston/model/team" "houston/pkg/slackbot" + incidentService "houston/service/incident" + request "houston/service/request" "strings" "time" @@ -30,10 +33,18 @@ type CreateIncidentAction struct { slackbotClient *slackbot.Client teamRepository *team.Repository severityRepository *severity.Repository + db *gorm.DB } -func NewCreateIncidentProcessor(client *socketmode.Client, logger *zap.Logger, incidentService *incident.Repository, - teamService *team.Repository, severityService *severity.Repository, slackbotClient *slackbot.Client) *CreateIncidentAction { +func NewCreateIncidentProcessor( + client *socketmode.Client, + logger *zap.Logger, + incidentService *incident.Repository, + teamService *team.Repository, + severityService *severity.Repository, + slackbotClient *slackbot.Client, + db *gorm.DB, +) *CreateIncidentAction { return &CreateIncidentAction{ client: client, logger: logger, @@ -41,16 +52,17 @@ func NewCreateIncidentProcessor(client *socketmode.Client, logger *zap.Logger, i teamRepository: teamService, severityRepository: severityService, slackbotClient: slackbotClient, + db: db, } } func (isp *CreateIncidentAction) CreateIncidentModalCommandProcessing(callback slack.InteractionCallback, request *socketmode.Request) { // Build create incident request - createIncidentRequest := buildCreateIncidentRequest(callback) + createIncidentRequest := buildCreateIncidentDTO(callback) isp.logger.Info("[CIP] incident request created", zap.Any("request", createIncidentRequest)) // Save the incident to the database - incidentEntity, err := isp.incidentRepository.CreateIncident(createIncidentRequest) + incidentEntity, err := isp.incidentRepository.CreateIncidentEntity(createIncidentRequest) if err != nil { isp.logger.Error("[CIP] Error while creating incident", zap.Error(err)) return @@ -104,6 +116,15 @@ func (isp *CreateIncidentAction) CreateIncidentModalCommandProcessing(callback s isp.logger.Error("[CIP] error while assigning responder to the incident ", zap.Error(err)) } + if incidentEntity.SeverityId >= 3 && incidentEntity.Status <= 4 { + slaDate := time.Now().AddDate(0, 0, severityEntity.Sla).Format("02 Jan 2006") + message := fmt.Sprintf("SLA for this incident is `%v day(s)` and will be closed by `%s`", severityEntity.Sla, + slaDate) + err := util.PostMessageToIncidentChannel(message, incidentEntity.SlackChannel, isp.client) + if err != nil { + return + } + } if viper.GetBool("ENABLE_GMEET") { gmeet, err := createGmeetLink(isp.logger, *channelID) if err != nil { @@ -136,6 +157,30 @@ func (isp *CreateIncidentAction) CreateIncidentModalCommandProcessing(callback s }() } +func (isp *CreateIncidentAction) CreateIncidentModalCommandProcessingV2( + callback slack.InteractionCallback, + request *socketmode.Request, +) { + // Build create incident request + createIncidentRequest, err := buildCreateIncidentRequestV2(callback) + if err != nil { + isp.logger.Error("[CIP] Error in building CreateIncidentRequestV2", zap.Error(err)) + return + } + isp.logger.Info("[CIP] incident request created", zap.Any("request", createIncidentRequest)) + + service := incidentService.NewIncidentServiceV2(isp.logger, isp.db) + _, err = service.CreateIncident(*createIncidentRequest, "SLACK", callback.View.PrivateMetadata) + if err != nil { + isp.logger.Error("[CIP] Error while creating incident", zap.Error(err)) + return + } + + // Acknowledge the interaction callback + var payload interface{} + isp.client.Ack(*request, payload) +} + func createGmeetLink(logger *zap.Logger, channelName string) (string, error) { calclient, err := calendar.NewService(context.Background(), option.WithCredentialsFile(viper.GetString("GMEET_CONFIG_FILE_PATH"))) if err != nil { @@ -220,11 +265,14 @@ func (isp *CreateIncidentAction) postIncidentSummary(blazeGroupChannelID, incide _, timestamp, err := isp.client.PostMessage(blazeGroupChannelID, slack.MsgOptionAttachments(att)) if err == nil { - isp.incidentRepository.CreateIncidentChannelEntry(&incident.CreateIncidentChannelEntry{ + err := isp.incidentRepository.CreateIncidentChannelEntry(&incident.CreateIncidentChannelEntry{ SlackChannel: blazeGroupChannelID, MessageTimeStamp: timestamp, IncidentId: incidentEntity.ID, }) + if err != nil { + return nil, err + } } else { return nil, fmt.Errorf("[CIP] error in saving message %v", err) } @@ -262,9 +310,9 @@ func (isp *CreateIncidentAction) getTeamAndSeverityAndStatus(teamId, severityId, return teamEntity, severityEntity, incidentStatusEntity, nil } -func buildCreateIncidentRequest(callback slack.InteractionCallback) *incident.CreateIncidentRequest { +func buildCreateIncidentDTO(callback slack.InteractionCallback) *incident.CreateIncidentDTO { blockActions := callback.View.State.Values - var createIncidentRequest incident.CreateIncidentRequest + var createIncidentDTO incident.CreateIncidentDTO var requestMap = make(map[string]string, 0) for _, actions := range blockActions { @@ -279,13 +327,39 @@ func buildCreateIncidentRequest(callback slack.InteractionCallback) *incident.Cr } desRequestMap, _ := json.Marshal(requestMap) - json.Unmarshal(desRequestMap, &createIncidentRequest) + json.Unmarshal(desRequestMap, &createIncidentDTO) - createIncidentRequest.Status = incident.Investigating - createIncidentRequest.StartTime = time.Now() - createIncidentRequest.EnableReminder = false - createIncidentRequest.CreatedBy = callback.User.ID - createIncidentRequest.UpdatedBy = callback.User.ID + createIncidentDTO.Status = incident.Investigating + createIncidentDTO.StartTime = time.Now() + createIncidentDTO.EnableReminder = false + createIncidentDTO.CreatedBy = callback.User.ID + createIncidentDTO.UpdatedBy = callback.User.ID - return &createIncidentRequest + return &createIncidentDTO +} + +func buildCreateIncidentRequestV2(callback slack.InteractionCallback) (*request.CreateIncidentRequestV2, error) { + blockActions := callback.View.State.Values + var createIncidentRequest request.CreateIncidentRequestV2 + var requestMap = make(map[string]string) + + for _, actions := range blockActions { + for actionID, a := range actions { + if string(a.Type) == string(slack.METPlainTextInput) { + requestMap[actionID] = a.Value + } + if string(a.Type) == slack.OptTypeStatic { + requestMap[actionID] = a.SelectedOption.Value + } + } + } + + desRequestMap, _ := json.Marshal(requestMap) + err := json.Unmarshal(desRequestMap, &createIncidentRequest) + if err != nil { + return nil, err + } + createIncidentRequest.CreatedBy = callback.User.ID + + return &createIncidentRequest, nil } diff --git a/internal/processor/event_type_interactive_processor.go b/internal/processor/event_type_interactive_processor.go index 15e6d98..a3dd1c8 100644 --- a/internal/processor/event_type_interactive_processor.go +++ b/internal/processor/event_type_interactive_processor.go @@ -2,6 +2,8 @@ package processor import ( "fmt" + "github.com/spf13/viper" + "gorm.io/gorm" "houston/common/util" "houston/internal/processor/action" "houston/model/incident" @@ -192,16 +194,25 @@ type ViewSubmissionProcessor struct { incidentUpdateRca *action.IncidentUpdateRcaAction showIncidentSubmitAction *action.ShowIncidentsSubmitAction incidentDuplicateAction *action.DuplicateIncidentAction + db *gorm.DB } -func NewViewSubmissionProcessor(logger *zap.Logger, socketModeClient *socketmode.Client, incidentRepository *incident.Repository, - teamService *team.Repository, severityService *severity.Repository, tagService *tag.Repository, teamRepository *team.Repository, - slackbotClient *slackbot.Client) *ViewSubmissionProcessor { +func NewViewSubmissionProcessor( + logger *zap.Logger, + socketModeClient *socketmode.Client, + incidentRepository *incident.Repository, + teamService *team.Repository, + severityService *severity.Repository, + tagService *tag.Repository, + teamRepository *team.Repository, + slackbotClient *slackbot.Client, + db *gorm.DB, +) *ViewSubmissionProcessor { return &ViewSubmissionProcessor{ logger: logger, socketModeClient: socketModeClient, incidentChannelMessageUpdateAction: action.NewIncidentChannelMessageUpdateAction(socketModeClient, logger, incidentRepository, teamService, severityService), - createIncidentAction: action.NewCreateIncidentProcessor(socketModeClient, logger, incidentRepository, teamService, severityService, slackbotClient), + createIncidentAction: action.NewCreateIncidentProcessor(socketModeClient, logger, incidentRepository, teamService, severityService, slackbotClient, db), assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, logger, incidentRepository), updateIncidentAction: action.NewIncidentUpdateAction(socketModeClient, logger, incidentRepository, tagService, teamService, severityService), incidentUpdateTitleAction: action.NewIncidentUpdateTitleAction(socketModeClient, logger, incidentRepository, teamService, severityService, slackbotClient), @@ -226,7 +237,12 @@ func (vsp *ViewSubmissionProcessor) ProcessCommand(callback slack.InteractionCal switch callbackId { case util.StartIncidentSubmit: { - vsp.createIncidentAction.CreateIncidentModalCommandProcessing(callback, request) + createIncidentV2Enabled := viper.GetBool("CREATE_INCIDENT_V2_ENABLED") + if createIncidentV2Enabled { + vsp.createIncidentAction.CreateIncidentModalCommandProcessingV2(callback, request) + } else { + vsp.createIncidentAction.CreateIncidentModalCommandProcessing(callback, request) + } } case util.AssignIncidentRoleSubmit: { diff --git a/model/incident/incident.go b/model/incident/incident.go index 94d560c..5b4101a 100644 --- a/model/incident/incident.go +++ b/model/incident/incident.go @@ -25,7 +25,7 @@ func NewIncidentRepository(logger *zap.Logger, gormClient *gorm.DB, severityServ } } -func (r *Repository) CreateIncident(request *CreateIncidentRequest) (*IncidentEntity, error) { +func (r *Repository) CreateIncidentEntity(request *CreateIncidentDTO) (*IncidentEntity, error) { severityId, err := strconv.Atoi(request.Severity) if err != nil { return nil, fmt.Errorf("fetch channel conversationInfo failed. err: %v", err) diff --git a/model/incident/model.go b/model/incident/model.go index 4cc1402..9a1a3fe 100644 --- a/model/incident/model.go +++ b/model/incident/model.go @@ -31,7 +31,7 @@ func (j JSON) Value() (driver.Value, error) { return json.RawMessage(j).MarshalJSON() } -type CreateIncidentRequest struct { +type CreateIncidentDTO struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Severity string `json:"severity,omitempty"` diff --git a/service/auth_service.go b/service/auth_service.go index 8d1cbcb..d858d9b 100644 --- a/service/auth_service.go +++ b/service/auth_service.go @@ -64,3 +64,27 @@ func (authService *AuthService) checkIfManagerOrAdmin( } return strings.Contains(userRoles, string(Admin)), nil } + +func (authService *AuthService) checkIfAdminOrTeamMember(context *gin.Context, teamMembers []string) (bool, error) { + sessionToken := context.Request.Header.Get("X-Session-Token") + userEmail := context.Request.Header.Get("X-User-Email") + sessionResponse, err := authService.mjolnirClient.GetSessionResponse(sessionToken) + if err != nil || sessionResponse.StatusCode == 401 { + authService.logger.Error(fmt.Sprintf("error occurred while getting session data from mjolnir for %v", userEmail), zap.Error(err)) + return false, nil + } + if sessionResponse.EmailId != userEmail { + authService.logger.Error(fmt.Sprintf("user email: %v does not match the email linked to the session token", userEmail)) + return false, nil + } + if teamMembers == nil || len(teamMembers) == 0 { + return strings.Contains(strings.Join(sessionResponse.Roles, ","), string(Admin)), nil + } + userInfo, err := authService.slackClient.GetUserByEmail(userEmail) + if err != nil { + authService.logger.Error(fmt.Sprintf("User with email %v was not found in slack", userEmail), zap.Error(err)) + return false, nil + } + return strings.Contains(strings.Join(sessionResponse.Roles, ","), string(Admin)) || + strings.Contains(strings.Join(teamMembers, ","), userInfo.ID), nil +} diff --git a/service/incident/incident_service_v2.go b/service/incident/incident_service_v2.go new file mode 100644 index 0000000..f8d6432 --- /dev/null +++ b/service/incident/incident_service_v2.go @@ -0,0 +1,628 @@ +package incident + +import ( + "context" + "encoding/json" + "fmt" + "github.com/google/uuid" + slackClient "github.com/slack-go/slack" + "github.com/spf13/viper" + "go.uber.org/zap" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + "gorm.io/gorm" + "houston/common/util" + "houston/internal/processor/action/view" + "houston/model/incident" + "houston/model/severity" + "houston/model/team" + "houston/model/user" + request "houston/service/request" + service "houston/service/response" + "houston/service/slack" + "strconv" + "strings" + "time" +) + +type IncidentServiceV2 struct { + logger *zap.Logger + db *gorm.DB + slackService *slack.SlackService + teamRepository *team.Repository + severityRepository *severity.Repository + incidentRepository *incident.Repository + userRepository *user.Repository +} + +func NewIncidentServiceV2(logger *zap.Logger, db *gorm.DB) *IncidentServiceV2 { + teamRepository := team.NewTeamRepository(logger, db) + severityRepository := severity.NewSeverityRepository(logger, db) + incidentRepository := incident.NewIncidentRepository(logger, db, severityRepository) + userRepository := user.NewUserRepository(logger, db) + slackService := slack.NewSlackService(logger) + return &IncidentServiceV2{ + logger: logger, + db: db, + slackService: slackService, + teamRepository: teamRepository, + severityRepository: severityRepository, + incidentRepository: incidentRepository, + userRepository: userRepository, + } +} + +const logTag = "[create-incident-v2]" + +/* + gets validated request + creates entry in DB, creates slack channel, posts incident summary, adds members, tags oncall, assigns responder + returns error if failure otherwise nil +*/ + +func (i *IncidentServiceV2) CreateIncident( + request request.CreateIncidentRequestV2, + source string, + blazeGroupChannelID string, +) (service.IncidentResponse, error) { + emptyResponse := service.IncidentResponse{} + // Create incident dto + i.logger.Info(fmt.Sprintf("%s received request to create incident: %+v", logTag, request)) + teamEntity, severityEntity, err := getTeamAndSeverityEntity(i, request.TeamID, request.SeverityID) + if err != nil { + return emptyResponse, err + } + incidentDTO, err := buildCreateIncidentDTO(request, i.teamRepository, i.severityRepository, i.slackService) + if err != nil { + return emptyResponse, err + } + i.logger.Info(fmt.Sprintf("%s CreateIncidentDTO created", logTag)) + + // Save the incident to the database + incidentEntity, err := i.incidentRepository.CreateIncidentEntity(incidentDTO) + if err != nil { + i.logger.Error(fmt.Sprintf("%s Error while creating incident", logTag), zap.Error(err)) + return emptyResponse, err + } + incidentName := incidentEntity.IncidentName + i.logger.Info(fmt.Sprintf("%s Incident entity created. Incident is: %s", logTag, incidentName)) + + // Create slack channel + // Call slack service to create a slack channel for incident + channel, err := i.slackService.CreateSlackChannel(incidentEntity.ID) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Error while crating slack channel", logTag, incidentName), + zap.Error(err), + ) + return emptyResponse, err + } + i.logger.Info(fmt.Sprintf( + "%s [%s] Slack channel created. Channel name is %s", logTag, incidentName, channel.Name), + ) + // Update channel details to incident entity + incidentEntity.SlackChannel = channel.ID + incidentEntity.IncidentName = channel.Name + err = i.incidentRepository.UpdateIncident(incidentEntity) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Failed to update the slack channel details in DB", logTag, incidentName)) + return emptyResponse, err + } + i.logger.Info(fmt.Sprintf("%s [%s] Slack channel details updated to incident entity", logTag, incidentName)) + + // Post incident summary + // Call slack service, provide required message to be posted + incidentStatusEntity, err := getIncidentStatusEntity(i, incidentEntity.ID, incidentEntity.Status, incidentName) + if err != nil { + return emptyResponse, err + } + i.logger.Info(fmt.Sprintf("%s [%s] Team, Severity and IncidentStatus entity fetched successfully", logTag, incidentName)) + + err = i.slackService.SetChannelTopic(channel, teamEntity, severityEntity, incidentEntity) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Failed to set channel topic", logTag, incidentName), + zap.Error(err), + ) + } + i.logger.Info(fmt.Sprintf("%s [%s] Channel topic is set", logTag, incidentName)) + + if source == "SLACK" { + go func() { + err := createIncidentWorkflow(i, channel, incidentEntity, teamEntity, severityEntity, incidentStatusEntity, blazeGroupChannelID) + if err != nil { + return + } + }() + } else { + err = createIncidentWorkflow(i, channel, incidentEntity, teamEntity, severityEntity, incidentStatusEntity, blazeGroupChannelID) + if err != nil { + return service.IncidentResponse{}, err + } + } + + go postInWebhookSlackChannel(i, teamEntity, incidentEntity, severityEntity) + + return service.ConvertToIncidentResponse(*incidentEntity), nil +} + +func createIncidentWorkflow( + i *IncidentServiceV2, + channel *slackClient.Channel, + incidentEntity *incident.IncidentEntity, + teamEntity *team.TeamEntity, + severityEntity *severity.SeverityEntity, + incidentStatusEntity *incident.IncidentStatusEntity, + blazeGroupChannelID string, +) error { + incidentName := incidentEntity.IncidentName + timestamp, err := postIncidentSummary( + channel.ID, + incidentEntity, + teamEntity, + severityEntity, + incidentStatusEntity, + i.incidentRepository, + blazeGroupChannelID, + i.slackService, + ) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Error in posting incident summary to slack channel", logTag, incidentName), + zap.Error(err), + ) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] Incident summary posted", logTag, incidentName)) + // Update incident channel details + err = i.incidentRepository.CreateIncidentChannelEntry(&incident.CreateIncidentChannelEntry{ + SlackChannel: channel.ID, + MessageTimeStamp: *timestamp, + IncidentId: incidentEntity.ID, + }) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Error in creating incident channel entry: %d", logTag, incidentName), + zap.Error(err), + ) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] Incident channel details updated to DB", logTag, incidentName)) + + // Add required members to channel + // Add incident creator to the channel + // Check if the user is a slack user and get the slack user id + isSlackUser, slackUserId := i.userRepository.IsAHoustonUser(incidentEntity.CreatedBy) + + if isSlackUser { + // Add user who created the incident + err := i.slackService.InviteUsersToConversation(incidentEntity.SlackChannel, slackUserId) + if err != nil { + i.logger.Error( + fmt.Sprintf( + "%s [%s] Failed to add the incident creator %s to the channel: %s", + logTag, incidentName, incidentEntity.CreatedBy, incidentEntity.SlackChannel, + ), + ) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] Incident creator is added to the slack channel", logTag, incidentName)) + } + // Call addMembersToIncident(), provide channel, team name and severity + err = addMembersToIncident(channel, i, teamEntity, severityEntity, incidentName) + if err != nil { + _, err := i.slackService.PostMessage( + fmt.Sprintf("`"+"Failed to add some team members to the incident channel"+"`"), + false, + channel, + ) + if err != nil { + i.logger.Debug(fmt.Sprintf("%s Failed to add members to the incident channel", logTag), zap.Error(err)) + } + } + i.logger.Info(fmt.Sprintf("%s [%s] All the members got added to the incident channel", logTag, incidentName)) + + // Tag oncall + // Call slack service to tag the oncall, provide incident id and slack id to be tagged + err = tagPseOrDevOncallToIncident(channel, severityEntity, teamEntity, i) + if err != nil { + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] oncall us tagged to the incident", logTag, incidentName)) + + // Assign responder + // Call assignResponder(), provide incident id, team service should assign responder + + err = assignResponderToIncident( + i, + incidentEntity, + teamEntity, + severityEntity, + incidentName, + ) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Failed to assign responder to the incident", logTag, incidentName), + zap.Error(err), + ) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] Responder is assigned to the incident", logTag, incidentName)) + + // Post message about the SLA + if incidentEntity.SeverityId >= 3 && incidentEntity.Status <= 4 { + slaDate := time.Now().AddDate(0, 0, severityEntity.Sla).Format("02 Jan 2006") + message := fmt.Sprintf( + "SLA for this incident is `%v day(s)` and will be closed by `%s`", + severityEntity.Sla, + slaDate, + ) + _, err := i.slackService.PostMessage(message, false, channel) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Failed to SLA information to the slack channel", logTag, incidentName), zap.Error(err)) + return err + } + } + + if viper.GetBool("ENABLE_GMEET") { + gmeet, err := createGmeetLink(i.logger, channel.Name) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Error while creating gmeet", logTag, incidentName), zap.Error(err)) + } else { + _, err := i.slackService.PostMessage(fmt.Sprint("gmeet: ", gmeet), false, channel) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Failed to post Google Meet link: %s", logTag, incidentName, gmeet)) + } + } + i.logger.Info(fmt.Sprintf("%s [%s] Google Meeting link posted to the channel %s", logTag, incidentName, gmeet)) + } + return nil +} + +func postIncidentSummary( + incidentChannelID string, + incidentEntity *incident.IncidentEntity, + teamEntity *team.TeamEntity, + severityEntity *severity.SeverityEntity, + incidentStatusEntity *incident.IncidentStatusEntity, + incidentRepository *incident.Repository, + blazeGroupChannelID string, + slackService *slack.SlackService, +) (*string, error) { + // Post incident summary to Blaze Group channel and incident channel + blocks := view.IncidentSummarySection(incidentEntity, teamEntity, severityEntity, incidentStatusEntity) + color := util.GetColorBySeverity(incidentEntity.SeverityId) + if blazeGroupChannelID != "" { + timestamp, err := slackService.PostMessageBlocks(blazeGroupChannelID, blocks, color) + if err == nil { + err := incidentRepository.CreateIncidentChannelEntry(&incident.CreateIncidentChannelEntry{ + SlackChannel: blazeGroupChannelID, + MessageTimeStamp: timestamp, + IncidentId: incidentEntity.ID, + }) + if err != nil { + return nil, fmt.Errorf( + "failed to create incident channel entry for blazeGroupChannelID: %s", blazeGroupChannelID, + ) + } + } else { + return nil, fmt.Errorf("error in posting summary to the blazeGroup channel %v", err) + } + } + timestamp, err := slackService.PostMessageBlocks(incidentChannelID, blocks, color) + if incidentEntity.MetaData != nil { + msgOption, noMetaDataError := util.BuildSlackTextMessageFromMetaData(incidentEntity.MetaData, true) + if noMetaDataError == nil { + _, err = slackService.PostMessageOption(incidentChannelID, msgOption) + if err != nil { + return nil, fmt.Errorf( + "error in posting metagada to the incident channel: %s. error: %v", incidentChannelID, err, + ) + } + } + } + if err == nil { + err := incidentRepository.CreateIncidentChannelEntry(&incident.CreateIncidentChannelEntry{ + SlackChannel: incidentChannelID, + MessageTimeStamp: timestamp, + IncidentId: incidentEntity.ID, + }) + if err != nil { + return nil, fmt.Errorf( + "failed to create incident channel entry for channelID: %s", incidentChannelID, + ) + } + } else { + return nil, fmt.Errorf( + "error in posting summary to the incident channel: %s. error: %v", incidentChannelID, err, + ) + } + return ×tamp, nil +} + +func addMembersToIncident( + channel *slackClient.Channel, + i *IncidentServiceV2, + teamEntity *team.TeamEntity, + severityEntity *severity.SeverityEntity, + incidentName string, +) error { + // 1. get list of team members, list of members by severity + var userIdList []string + userIdList = append( + append( + append( + append(userIdList, teamEntity.SlackUserIds...), + severityEntity.SlackUserIds..., + ), + teamEntity.OncallHandle, + ), + teamEntity.PseOncallHandle, + ) + err := i.slackService.InviteUsersToConversation(channel.ID, userIdList[:]...) + if err != nil { + i.logger.Error( + fmt.Sprintf( + "%s [%s] Error in adding members [%+v] to the channel %s", + logTag, incidentName, userIdList, channel.Name, + ), + zap.Error(err), + ) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] All the members are added to the slack channel", logTag, incidentName)) + return nil +} + +func assignResponderToIncident( + i *IncidentServiceV2, + incidentEntity *incident.IncidentEntity, + teamEntity *team.TeamEntity, + severityEntity *severity.SeverityEntity, + incidentName string, +) error { + // 1. find the responder (dev or pse oncall) + var responder, nonEmpty = getOncallOrResponderHandle(teamEntity, severityEntity) + // 2. updated responder in db + if nonEmpty { + var addIncidentRoleRequest = incident.AddIncidentRoleRequest{ + UserId: responder, + Role: incident.Responder, + IncidentId: int(incidentEntity.ID), + CreatedById: incidentEntity.CreatedBy, + } + err := i.incidentRepository.UpsertIncidentRole(&addIncidentRoleRequest) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Failed to upsert incident_role", logTag, incidentName), zap.Error(err)) + return err + } + i.logger.Info(fmt.Sprintf("%s [%s] Incident role upserted", logTag, incidentName)) + _, err = i.slackService.PostMessageByChannelID( + fmt.Sprintf("<@%s> is assigned as *%s* by <@%s>", addIncidentRoleRequest.UserId, addIncidentRoleRequest.Role, addIncidentRoleRequest.CreatedById), + false, + incidentEntity.SlackChannel, + ) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Post response failed for AssignResponderToIncident", logTag, incidentName), zap.Error(err)) + return err + } + } + return nil +} + +func buildCreateIncidentDTO( + createIncRequest request.CreateIncidentRequestV2, + teamRepository *team.Repository, + severityRepository *severity.Repository, + slackService *slack.SlackService, +) (*incident.CreateIncidentDTO, error) { + var createIncidentRequest incident.CreateIncidentDTO + teamID, err := strconv.ParseUint(createIncRequest.TeamID, 10, 64) + if err != nil { + //todo handle error + } + teamEntity, err := teamRepository.FindTeamById(uint(teamID)) + if err != nil { + return nil, err + } + + severityID, err := strconv.ParseUint(createIncRequest.SeverityID, 10, 64) + if err != nil { + //todo handle error + } + severityEntity, err := severityRepository.FindSeverityById(uint(severityID)) + if err != nil { + return nil, err + } + + var rawJson, _ = json.Marshal([]request.CreateIncidentMetaData{createIncRequest.MetaData}) + createdBy := slackService.GetSlackUserIdOrEmail(createIncRequest.CreatedBy) + + createIncidentRequest.Title = createIncRequest.Title + createIncidentRequest.Description = createIncRequest.Description + createIncidentRequest.Severity = fmt.Sprintf("%d", severityEntity.ID) + createIncidentRequest.TeamId = fmt.Sprintf("%d", teamEntity.ID) + createIncidentRequest.Status = incident.Investigating + createIncidentRequest.CreatedBy = createdBy + createIncidentRequest.UpdatedBy = createdBy + createIncidentRequest.StartTime = time.Now() + createIncidentRequest.EnableReminder = false + createIncidentRequest.MetaData = rawJson + return &createIncidentRequest, nil +} + +func getIncidentStatusEntity( + i *IncidentServiceV2, + incidentID, + statusID uint, + incidentName string, +) (*incident.IncidentStatusEntity, error) { + incidentStatusEntity, err := i.incidentRepository.FindIncidentStatusById(statusID) + if err != nil || incidentStatusEntity == nil { + i.logger.Error( + fmt.Sprintf( + "%s [%s] Error in finding incident statusID entity for statusID: %d in incidentID: %d", + logTag, incidentName, statusID, incidentID, + ), + ) + return nil, err + } + + return incidentStatusEntity, nil +} + +func getTeamAndSeverityEntity( + i *IncidentServiceV2, + team, + severity string, +) (*team.TeamEntity, *severity.SeverityEntity, error) { + teamID, err := strconv.ParseUint(team, 10, 64) + teamEntity, err := i.teamRepository.FindTeamById(uint(teamID)) + if err != nil || teamEntity == nil { + i.logger.Error( + fmt.Sprintf("%s Error in finding team entity for team ID: %d", logTag, teamID), + ) + return nil, nil, err + } + + severityID, err := strconv.ParseUint(severity, 10, 64) + severityEntity, err := i.severityRepository.FindSeverityById(uint(severityID)) + if err != nil || severityEntity == nil { + i.logger.Error( + fmt.Sprintf("%s Error in finding severity entity for severity ID: %d", logTag, severityID), + ) + return nil, nil, err + } + return teamEntity, severityEntity, nil +} + +func tagPseOrDevOncallToIncident( + channel *slackClient.Channel, + severityEntity *severity.SeverityEntity, + teamEntity *team.TeamEntity, + i *IncidentServiceV2, +) error { + incidentName := channel.Name + onCallToBeTagged, nonEmpty := getOncallOrResponderHandle(teamEntity, severityEntity) + if nonEmpty { + //oncall handles should already be added to channel + /*err := i.slackService.InviteUsersToConversation(channelId, onCallToBeTagged) + if err != nil { + i.logger.Error(fmt.Sprintf("%s Failed to invite oncall to the conversation")) + return err + }*/ + ts, err := i.slackService.PostMessage(fmt.Sprintf("<@%s>", onCallToBeTagged), false, channel) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Failed to tag oncall in channel: %s", logTag, incidentName, channel.Name), + zap.Error(err), + ) + return err + } + + go func() { + time.Sleep(3 * time.Second) + msg, _, _, _ := i.slackService.GetConversationReplies(channel.ID, ts, 2) + if len(msg) > 1 { + //User id needs to sliced from `<@XXXXXXXXXXXX>` format to `XXXXXXXXXXXX` + err := i.slackService.InviteUsersToConversation(channel.ID, msg[1].Text[2:13]) + if err != nil { + i.logger.Error(fmt.Sprintf("%s [%s] Failed to invite the user tagged by the oncall bot", logTag, incidentName)) + return + } + i.logger.Info(fmt.Sprintf("%s [%s] User tagged by the oncall bot is invited to the incident channel", logTag, incidentName)) + } + }() + } + return nil +} + +func getOncallOrResponderHandle(teamEntity *team.TeamEntity, severityEntity *severity.SeverityEntity) (string, bool) { + onCallOrResponderHandle := "" + if isPseIncident(teamEntity, severityEntity) { + onCallOrResponderHandle = teamEntity.PseOncallHandle + } else { + onCallOrResponderHandle = teamEntity.OncallHandle + } + return onCallOrResponderHandle, len(strings.TrimSpace(onCallOrResponderHandle)) > 0 +} + +func isPseIncident(teamEntity *team.TeamEntity, severityEntity *severity.SeverityEntity) bool { + return len(strings.TrimSpace(teamEntity.PseOncallHandle)) > 0 && (severityEntity.Name == "Sev-2" || severityEntity.Name == "Sev-3") +} + +func postInWebhookSlackChannel( + i *IncidentServiceV2, + teamEntity *team.TeamEntity, + incidentEntity *incident.IncidentEntity, + severityEntity *severity.SeverityEntity, +) { + incidentName := incidentEntity.IncidentName + if len(teamEntity.WebhookSlackChannel) == 0 { + return + } + + msg := fmt.Sprintf( + "*<@%s>* started an Incident\n `%s(%s) %s` :slack: <#%s>: %s", + incidentEntity.CreatedBy, + severityEntity.Name, + severityEntity.Description, + teamEntity.Name, + incidentEntity.SlackChannel, + incidentEntity.Title, + ) + _, err := i.slackService.PostMessageByChannelID(msg, false, teamEntity.WebhookSlackChannel) + if err != nil { + i.logger.Error( + fmt.Sprintf("%s [%s] Failed to post update in webhook slack channel: %s", logTag, incidentName, incidentEntity.SlackChannel), + zap.Error(err), + ) + i.logger.Info(fmt.Sprintf("%s [%s] Update posted to the webhook slack channel: %s", logTag, incidentName, incidentEntity.SlackChannel)) + return + } +} + +func createGmeetLink(logger *zap.Logger, channelName string) (string, error) { + incidentName := channelName + calclient, err := calendar.NewService(context.Background(), option.WithCredentialsFile(viper.GetString("GMEET_CONFIG_FILE_PATH"))) + if err != nil { + logger.Error( + fmt.Sprintf("%s [%s]Calander service creation failed, unable to read client secret file: ", logTag, incidentName), + zap.Error(err), + ) + return "", err + } + t0 := time.Now().Format(time.RFC3339) + t1 := time.Now().Add(1 * time.Hour).Format(time.RFC3339) + event := &calendar.Event{ + Summary: channelName, + Description: "Incident", + Start: &calendar.EventDateTime{ + DateTime: t0, + }, + End: &calendar.EventDateTime{ + DateTime: t1, + }, + ConferenceData: &calendar.ConferenceData{ + CreateRequest: &calendar.CreateConferenceRequest{ + RequestId: uuid.NewString(), + ConferenceSolutionKey: &calendar.ConferenceSolutionKey{ + Type: "hangoutsMeet", + }, + Status: &calendar.ConferenceRequestStatus{ + StatusCode: "success", + }, + }, + }, + } + + calendarID := "primary" //use "primary" + event, err = calclient.Events.Insert(calendarID, event).ConferenceDataVersion(1).Do() + if err != nil { + logger.Error("Unable to create event. %v\n", zap.Error(err)) + return "", err + } + + calclient.Events.Delete(calendarID, event.Id).Do() + return event.HangoutLink, nil +} diff --git a/service/incident_service.go b/service/incident_service.go index afb628f..74fc361 100644 --- a/service/incident_service.go +++ b/service/incident_service.go @@ -333,13 +333,13 @@ func (i *incidentService) CreateIncident(c *gin.Context) { return } - incidentRequest, err := i.buildCreateIncidentRequest(createIncRequest) + incidentDTO, err := i.buildCreateIncidentDTO(createIncRequest) if err != nil { c.JSON(http.StatusBadRequest, err) } // Save the incident to the database - incidentEntity, err := i.incidentRepository.CreateIncident(incidentRequest) + incidentEntity, err := i.incidentRepository.CreateIncidentEntity(incidentDTO) if err != nil { i.logger.Error("[Create incident Api] Error while creating incident", zap.Error(err)) return @@ -351,7 +351,7 @@ func (i *incidentService) CreateIncident(c *gin.Context) { incidentEntity.Status, ) if err != nil { - i.logger.Error("[CIP] failed whilte getting team, severity and status", zap.Error(err)) + i.logger.Error("[CIP] failed while getting team, severity and status", zap.Error(err)) return } @@ -396,6 +396,16 @@ func (i *incidentService) CreateIncident(c *gin.Context) { i.logger.Error("[Create incident] Error while assigning responder to the incident ", zap.Error(err)) } + if incidentEntity.SeverityId >= 3 && incidentEntity.Status <= 4 { + slaDate := time.Now().AddDate(0, 0, severityEntity.Sla).Format("02 Jan 2006") + message := fmt.Sprintf("SLA for this incident is `%v day(s)` and will be closed by `%s`", severityEntity.Sla, + slaDate) + err := util.PostMessageToIncidentChannel(message, incidentEntity.SlackChannel, i.socketModeClient) + if err != nil { + return + } + } + incidentResponse := service.ConvertToIncidentResponse(*incidentEntity) c.JSON(http.StatusOK, common.SuccessResponse(incidentResponse, http.StatusOK)) } @@ -499,8 +509,8 @@ func (i *incidentService) createSlackChannel(incidentEntity *incident.IncidentEn return &channelID, nil } -func (i *incidentService) buildCreateIncidentRequest(createIncRequest request.CreateIncidentRequest) (*incident.CreateIncidentRequest, error) { - var createIncidentRequest incident.CreateIncidentRequest +func (i *incidentService) buildCreateIncidentDTO(createIncRequest request.CreateIncidentRequest) (*incident.CreateIncidentDTO, error) { + var createIncidentRequest incident.CreateIncidentDTO teamEntity, err := i.teamRepository.FindTeamByTeamName(createIncRequest.TeamName) if err != nil { return nil, err diff --git a/service/request/create_incident.go b/service/request/create_incident.go index 44e2cb0..ce877ae 100644 --- a/service/request/create_incident.go +++ b/service/request/create_incident.go @@ -14,6 +14,15 @@ type CreateIncidentRequest struct { MetaData CreateIncidentMetaData `gorm:"json:metaData"` } +type CreateIncidentRequestV2 struct { + Title string `json:"title"` + Description string `json:"description"` + SeverityID string `json:"severity"` + TeamID string `json:"type"` + CreatedBy string `json:"createdBy"` + MetaData CreateIncidentMetaData `json:"metaData"` +} + type CreateIncidentMetaData struct { CustomerId uuid.UUID `json:"customerId"` CustomerName string `json:"customerName"` diff --git a/service/slack/slack_service.go b/service/slack/slack_service.go new file mode 100644 index 0000000..6c14ca4 --- /dev/null +++ b/service/slack/slack_service.go @@ -0,0 +1,205 @@ +package slack + +import ( + "fmt" + "github.com/slack-go/slack" + "github.com/slack-go/slack/socketmode" + "github.com/spf13/viper" + "go.uber.org/zap" + "houston/model/incident" + "houston/model/severity" + "houston/model/team" + "os" + "strconv" + "strings" +) + +type SlackService struct { + SocketModeClient *socketmode.Client + logger *zap.Logger +} + +func NewSlackService(logger *zap.Logger) *SlackService { + return &SlackService{createSocketModeClient(logger), logger} +} + +const logTag = "[slack-service]" + +func (s *SlackService) PostMessage(message string, escape bool, channel *slack.Channel) (string, error) { + msgOption := slack.MsgOptionText(message, escape) + _, timeStamp, err := s.SocketModeClient.PostMessage(channel.ID, msgOption) + if err != nil { + e := fmt.Sprintf("%s Failed to post message into channel: %s", logTag, channel.Name) + s.logger.Error(e, zap.Error(err)) + return "", fmt.Errorf("%s - %+v", e, err) + } + return timeStamp, nil +} + +func (s *SlackService) PostMessageOption(channelID string, messageOption slack.MsgOption) (string, error) { + _, timeStamp, err := s.SocketModeClient.PostMessage(channelID, messageOption) + if err != nil { + e := fmt.Sprintf("%s Failed to post message into channelID: %s", logTag, channelID) + s.logger.Error(e, zap.Error(err)) + return "", fmt.Errorf("%s - %+v", e, err) + } + return timeStamp, nil +} + +func (s *SlackService) PostMessageByChannelID(message string, escape bool, channelID string) (string, error) { + msgOption := slack.MsgOptionText(message, escape) + _, timeStamp, err := s.SocketModeClient.PostMessage(channelID, msgOption) + if err != nil { + e := fmt.Sprintf("%s Failed to post message into channel: %s", logTag, channelID) + s.logger.Error(e, zap.Error(err)) + return "", fmt.Errorf("%s : %+v", e, err) + } + return timeStamp, 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) + _, timestamp, err := s.SocketModeClient.PostMessage(channelID, messageOption) + if err != nil { + return "", err + } + return timestamp, nil +} + +func (s *SlackService) GetConversationReplies(channelID string, timeStamp string, limit int) (msgs []slack.Message, hasMore bool, nextCursor string, err error) { + return s.SocketModeClient.GetConversationReplies( + &slack.GetConversationRepliesParameters{ChannelID: channelID, Timestamp: timeStamp, Limit: limit}, + ) +} + +func (s *SlackService) SetChannelTopic( + channel *slack.Channel, + teamEntity *team.TeamEntity, + severityEntity *severity.SeverityEntity, + incidentEntity *incident.IncidentEntity, +) error { + topic := fmt.Sprintf( + "%s-%s(%s) Incident-%d | %s", + teamEntity.Name, + severityEntity.Name, + severityEntity.Description, + incidentEntity.ID, + incidentEntity.Title, + ) + _, err := s.SocketModeClient.SetTopicOfConversation(channel.ID, topic) + if err != nil { + e := fmt.Sprintf("%s set topic on slack channel failed", logTag) + s.logger.Error(e, zap.String("channel_id", channel.Name), zap.Error(err)) + return fmt.Errorf("%s : %+v", e, err) + } + + s.logger.Info("set topic on slack channel successful", zap.String("channel_id", channel.Name)) + return nil +} + +func (s *SlackService) GetUserByEmail(userEmail string) (*slack.User, error) { + user, err := s.SocketModeClient.GetUserByEmail(userEmail) + if err != nil { + e := fmt.Sprintf("%s error in getting user by email: %s", logTag, userEmail) + s.logger.Error(e, zap.Error(err)) + return nil, fmt.Errorf("%s : %+v", e, err) + } + return user, nil +} + +func (s *SlackService) GetSlackUserIdOrEmail(email string) string { + userInfo, err := s.GetUserByEmail(email) + if err != nil { + return email + } else { + return userInfo.ID + } +} + +func (s *SlackService) CreateSlackChannel(incidentId uint) (*slack.Channel, error) { + channel, err := createChannel(getIncidentChannelName(incidentId), s.SocketModeClient, s.logger) + if err != nil { + return nil, fmt.Errorf("%s failed to create Slack Channel for incident %d. error: %+v", logTag, incidentId, err) + } + return channel, nil +} + +func (s *SlackService) InviteUsersToConversation(channelId string, userId ...string) error { + _, err := s.SocketModeClient.InviteUsersToConversation(channelId, userId...) + if err != nil { + e := fmt.Sprintf("%s failed to invite users: %+v to the channel: %s", logTag, userId, channelId) + s.logger.Error(e, zap.Error(err)) + return fmt.Errorf("%s : %+v", e, err) + } + + s.logger.Info( + "successfully invite users to conversation", + zap.String("channel_id", channelId), zap.Any("user_ids", userId), + ) + return nil +} + +func getIncidentChannelName(incidentID uint) string { + var channelPrefix string + if viper.GetString("env") != "prod" { + channelPrefix = "_test-issue-" + } else { + channelPrefix = "_houston-" + } + return channelPrefix + strconv.Itoa(int(incidentID)) +} + +func createChannel(channelName string, socketModeClient *socketmode.Client, logger *zap.Logger) (*slack.Channel, error) { + request := slack.CreateConversationParams{ + ChannelName: channelName, + IsPrivate: false, + } + + channel, err := socketModeClient.CreateConversation(request) + if err != nil { + e := fmt.Sprintf("%s failed to create slack channel with name: %s", logTag, channelName) + logger.Error(e, zap.Error(err)) + return nil, fmt.Errorf("%s : %+v", e, err) + } + + logger.Info("created slackbot channel successfully", zap.String("channel_name", channelName), zap.String("channel_id", channel.ID)) + return channel, nil +} + +func createSocketModeClient(logger *zap.Logger) *socketmode.Client { + appToken := viper.GetString("houston.slack.app.token") + if appToken == "" { + logger.Error("HOUSTON_SLACK_APP_TOKEN must be set.") + os.Exit(1) + } + + if !strings.HasPrefix(appToken, "xapp-") { + logger.Error("HOUSTON_SLACK_APP_TOKEN must have the prefix \"xapp-\".") + os.Exit(1) + } + + botToken := viper.GetString("houston.slack.bot.token") + if botToken == "" { + logger.Error("HOUSTON_SLACK_BOT_TOKEN must be set.") + os.Exit(1) + } + + if !strings.HasPrefix(botToken, "xoxb-") { + logger.Error("HOUSTON_SLACK_BOT_TOKEN must have the prefix \"xoxb-\".") + os.Exit(1) + } + + api := slack.New( + botToken, + slack.OptionDebug(false), + slack.OptionAppLevelToken(appToken), + ) + + client := socketmode.New( + api, + socketmode.OptionDebug(false), + ) + + return client +} diff --git a/service/team_service.go b/service/team_service.go index 9f54d7d..0ffdf58 100644 --- a/service/team_service.go +++ b/service/team_service.go @@ -258,7 +258,7 @@ func (t *TeamService) UpdateTeam(c *gin.Context) { authResult, _ := t.authService.checkIfManagerOrAdmin(c, teamEntity.ManagerHandle, Admin, Manager) if !authResult { - c.JSON(http.StatusUnauthorized, common.ErrorResponse(errors.New(fmt.Sprintf("%v is neither an admin nor the manager of %v team", userEmail, teamEntity.Name)), http.StatusUnauthorized, nil)) + c.JSON(http.StatusUnauthorized, common.ErrorResponse(errors.New(fmt.Sprintf("%v is neither an admin nor a member of %v team", userEmail, teamEntity.Name)), http.StatusUnauthorized, nil)) return } diff --git a/service/utils/validations.go b/service/utils/validations.go index f6cb6e8..36c55d0 100644 --- a/service/utils/validations.go +++ b/service/utils/validations.go @@ -64,6 +64,24 @@ func ValidateCreateIncidentRequest(request service.CreateIncidentRequest) error return nil } +func ValidateCreateIncidentRequestV2(request service.CreateIncidentRequestV2) error { + if request.Title == "" || request.Description == "" { + return errors.New("title and description should be present in create request") + } + descriptionMaxLength := viper.GetInt("create-incident.description.max-length") + titleMaxLength := viper.GetInt("create-incident.title.max-length") + if len(request.Description) > descriptionMaxLength { + return errors.New(fmt.Sprintf("description should not be more than %v characters long", descriptionMaxLength)) + } + if len(request.Title) > titleMaxLength { + return errors.New(fmt.Sprintf("title should not be more than %v characters long", titleMaxLength)) + } + if request.CreatedBy == "" { + return errors.New("created By should be present") + } + return nil +} + func ValidateUpdateTeamRequest(request service.UpdateTeamRequest) error { if request.Id == 0 { return errors.New("id should be present in update request")