diff --git a/common/util/constant.go b/common/util/constant.go index fcc8bb1..e7efde8 100644 --- a/common/util/constant.go +++ b/common/util/constant.go @@ -33,6 +33,7 @@ const ( SetIncidentTitleSubmit = "set_incident_title_submit" SetIncidentDescriptionSubmit = "set_incident_description_submit" SetIncidentSeveritySubmit = "set_incident_severity_submit" + SeverityJustificationSubmit = "severity_justification_submit" SetIncidentTypeSubmit = "set_incident_type_submit" SetIncidentRCADetailsSubmit = "set_rca_details_submit" IncidentResolveSubmit = "resolve_incident_submit" diff --git a/common/util/structUtil/struct_util.go b/common/util/structUtil/struct_util.go new file mode 100644 index 0000000..4bf8854 --- /dev/null +++ b/common/util/structUtil/struct_util.go @@ -0,0 +1,19 @@ +package structUtil + +import "encoding/json" + +func StructToString(structData interface{}) (string, error) { + jsonData, err := json.Marshal(structData) + if err != nil { + return "", err + } + return string(jsonData), nil +} + +func StringToStruct(stringData string, structData interface{}) error { + err := json.Unmarshal([]byte(stringData), &structData) + if err != nil { + return err + } + return nil +} diff --git a/db/migration/000012_add_log_column.up.sql b/db/migration/000012_add_log_column.up.sql new file mode 100644 index 0000000..7150005 --- /dev/null +++ b/db/migration/000012_add_log_column.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE log + ADD COLUMN IF NOT EXISTS justification character varying(255); diff --git a/internal/processor/action/incident_update_severity_action.go b/internal/processor/action/incident_update_severity_action.go index 1bb85d2..9c5e22b 100644 --- a/internal/processor/action/incident_update_severity_action.go +++ b/internal/processor/action/incident_update_severity_action.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/viper" incidentHelper "houston/common/util" + "houston/common/util/structUtil" "houston/internal/processor/action/view" "houston/logger" "houston/model/incident" @@ -48,6 +49,11 @@ func NewIncidentUpdateSeverityAction( } } +type JustificationMetadata struct { + SeverityId uint + ChannelId string +} + func (isp *IncidentUpdateSevertityAction) IncidentUpdateSeverityRequestProcess(callback slack.InteractionCallback, request *socketmode.Request) { incidentSeverity, err := isp.severityRepository.GetAllActiveSeverity() if err != nil || incidentSeverity == nil { @@ -68,9 +74,53 @@ func (isp *IncidentUpdateSevertityAction) IncidentUpdateSeverityRequestProcess(c isp.client.Ack(*request, payload) } +func (isp *IncidentUpdateSevertityAction) IncidentUpdateSeverityJustification(callback slack.InteractionCallback, request *socketmode.Request, user slack.User) { + var justificationMetadata JustificationMetadata + err := structUtil.StringToStruct(callback.View.PrivateMetadata, &justificationMetadata) + if err != nil { + logger.Error(fmt.Sprintf("error in converting string to justification metadata for trigger id: %s, privatemetadata: %s", + callback.TriggerID, callback.View.PrivateMetadata), zap.Error(err)) + return + } + severityId := justificationMetadata.SeverityId + channelId := justificationMetadata.ChannelId + + incidentEntity, err := isp.incidentRepository.FindIncidentByChannelId(channelId) + if err != nil { + logger.Error(fmt.Sprintf("error in fetching incident with channel id: %s", channelId), zap.Error(err)) + return + } else if incidentEntity == nil { + logger.Error(fmt.Sprintf("incident not found with channel id: %s", channelId)) + return + } + + teamEntity, _, incidentStatusEntity, incidentChannels, err := isp.incidentServiceV2.FetchAllEntitiesForIncident(incidentEntity) + if err != nil { + logger.Error(fmt.Sprintf("error in fetching entities for incident with id: %d", incidentEntity.ID), zap.Error(err)) + return + } + var payload interface{} + isp.client.Ack(*request, payload) + if err := isp.incidentServiceV2.UpdateSeverityId( + service.UpdateIncidentRequest{ + Id: incidentEntity.ID, + SeverityId: strconv.Itoa(int(severityId)), + Justification: callback.View.State.Values[view.JustificationBlockId][view.JustificationActionId].Value, + }, + user.ID, + incidentEntity, + teamEntity, + incidentStatusEntity, + incidentChannels, + ); err != nil { + logger.Error(fmt.Sprintf("error in updating severity: %v", err)) + } + return +} func (isp *IncidentUpdateSevertityAction) IncidentUpdateSeverity(callback slack.InteractionCallback, request *socketmode.Request, channel slack.Channel, user slack.User) { - incidentEntity, err := isp.incidentRepository.FindIncidentByChannelId(callback.View.PrivateMetadata) + channelId := callback.View.PrivateMetadata + incidentEntity, err := isp.incidentRepository.FindIncidentByChannelId(channelId) if err != nil { logger.Error("FindIncidentByChannelId error", zap.String("incident_slack_channel_id", channel.ID), zap.String("channel", channel.Name), @@ -86,13 +136,28 @@ func (isp *IncidentUpdateSevertityAction) IncidentUpdateSeverity(callback slack. incidentSeverityId := buildUpdateIncidentSeverityRequest(callback.View.State.Values) if viper.GetBool("UPDATE_INCIDENT_V2_ENABLED") { + var payload interface{} + isp.client.Ack(*request, payload) teamEntity, _, incidentStatusEntity, incidentChannels, err := isp.incidentServiceV2.FetchAllEntitiesForIncident(incidentEntity) if err != nil { logger.Error(fmt.Sprintf("error in fetching entities for incident with id: %d %v", incidentEntity.ID, err)) return } - var payload interface{} - isp.client.Ack(*request, payload) + if viper.GetBool("ENABLE_DE_ESCALATION_JUSTIFICATION") { + if uint(incidentSeverityId) > incidentEntity.SeverityId { + justificationMetadata := JustificationMetadata{SeverityId: uint(incidentSeverityId), ChannelId: channelId} + stringData, stringErr := structUtil.StructToString(justificationMetadata) + if stringErr != nil { + logger.Error(fmt.Sprintf("error in converting justification metadata to string for incident id: %d", incidentEntity.ID), zap.Error(stringErr)) + } + _, viewErr := isp.client.OpenView(callback.TriggerID, view.CreateSeverityJustificationBlock(stringData)) + if viewErr != nil { + logger.Error(fmt.Sprintf("error in opening justification view for incident with id: %d", incidentEntity.ID), zap.Error(viewErr)) + } + return + } + } + if err := isp.incidentServiceV2.UpdateSeverityId( service.UpdateIncidentRequest{ Id: incidentEntity.ID, diff --git a/internal/processor/action/set_severity_command_action.go b/internal/processor/action/set_severity_command_action.go index fb855ab..23b914f 100644 --- a/internal/processor/action/set_severity_command_action.go +++ b/internal/processor/action/set_severity_command_action.go @@ -4,12 +4,17 @@ import ( "fmt" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" + "github.com/spf13/viper" "go.uber.org/zap" "houston/appcontext" incidentHelper "houston/common/util" + "houston/common/util/structUtil" "houston/internal" + "houston/internal/processor/action/view" "houston/logger" "houston/pkg/slackbot" + service "houston/service/request" + "strconv" "strings" "time" ) @@ -72,6 +77,47 @@ func (action *SetSeverityCommandAction) setSeverity(cmd slack.SlashCommand, seve logger.Error(fmt.Sprintf("%s no DB entity found for %s. %+v", setSeverityActionLogTag, severity, err)) return fmt.Errorf("%s is not a valid severity", severity) } + + if viper.GetBool("UPDATE_INCIDENT_V2_ENABLED") { + incidentSeverityId := severityEntity.ID + teamEntity, _, incidentStatusEntity, incidentChannels, err := appcontext.GetIncidentService().FetchAllEntitiesForIncident(incidentEntity) + if err != nil { + logger.Error(fmt.Sprintf("error in fetching entities for incident with id: %d %v", incidentEntity.ID, err)) + return err + } + if viper.GetBool("ENABLE_DE_ESCALATION_JUSTIFICATION") { + if incidentSeverityId > incidentEntity.SeverityId { + justificationMetadata := JustificationMetadata{SeverityId: incidentSeverityId, ChannelId: cmd.ChannelID} + stringData, stringErr := structUtil.StructToString(justificationMetadata) + if stringErr != nil { + logger.Error(fmt.Sprintf("error in converting justification metadata to string for incident id: %d", incidentEntity.ID), zap.Error(stringErr)) + } + _, viewErr := action.socketModeClient.OpenView(cmd.TriggerID, view.CreateSeverityJustificationBlock(stringData)) + if viewErr != nil { + logger.Error(fmt.Sprintf("error in opening justification view for incident with id: %d", incidentEntity.ID), zap.Error(viewErr)) + return viewErr + } + return nil + } + } + + if err := appcontext.GetIncidentService().UpdateSeverityId( + service.UpdateIncidentRequest{ + Id: incidentEntity.ID, + SeverityId: strconv.Itoa(int(incidentSeverityId)), + }, + cmd.UserID, + incidentEntity, + teamEntity, + incidentStatusEntity, + incidentChannels, + ); err != nil { + logger.Error(fmt.Sprintf("error in updating severity: %v", err)) + return err + } + return nil + } + incidentEntity.SeverityId = severityEntity.ID incidentEntity.UpdatedBy = cmd.UserID incidentEntity.SeverityTat = time.Now().AddDate(0, 0, severityEntity.Sla) diff --git a/internal/processor/action/view/incident_severity.go b/internal/processor/action/view/incident_severity.go index 0552a0f..282d1c0 100644 --- a/internal/processor/action/view/incident_severity.go +++ b/internal/processor/action/view/incident_severity.go @@ -9,6 +9,11 @@ import ( "github.com/slack-go/slack" ) +const ( + JustificationBlockId = "justificationBlockId" + JustificationActionId = "justificationActionId" +) + func BuildIncidentUpdateSeverityModal(channelID string, incidentSeverity []severity.SeverityEntity) slack.ModalViewRequest { titleText := slack.NewTextBlockObject(slack.PlainTextType, "Set severity of incident", false, false) closeText := slack.NewTextBlockObject(slack.PlainTextType, "Close", false, false) @@ -51,3 +56,31 @@ func createIncidentSeverityBlock(options []severity.SeverityEntity) []*slack.Opt } return optionBlockObjects } + +func CreateSeverityJustificationBlock(metadata string) slack.ModalViewRequest { + subTitle := slack.NewTextBlockObject(slack.PlainTextType, "Justification", false, false) + closeText := slack.NewTextBlockObject(slack.PlainTextType, "Close", false, false) + submitText := slack.NewTextBlockObject(slack.PlainTextType, "Submit", false, false) + + inputPlaceHolder := slack.NewTextBlockObject(slack.PlainTextType, "Provide reason for reducing the severity of this incident", false, false) + + inputElement := slack.NewPlainTextInputBlockElement(inputPlaceHolder, JustificationActionId) + inputElement.MaxLength = 100 + justificationBlock := slack.NewInputBlock(JustificationBlockId, subTitle, nil, inputElement) + justificationBlock.Optional = false + + blocks := slack.Blocks{ + BlockSet: []slack.Block{ + justificationBlock, + }, + } + return slack.ModalViewRequest{ + Type: slack.VTModal, + Title: subTitle, + Close: closeText, + Submit: submitText, + Blocks: blocks, + PrivateMetadata: metadata, + CallbackID: util.SeverityJustificationSubmit, + } +} diff --git a/internal/processor/event_type_interactive_processor.go b/internal/processor/event_type_interactive_processor.go index 790f2a4..6c995d8 100644 --- a/internal/processor/event_type_interactive_processor.go +++ b/internal/processor/event_type_interactive_processor.go @@ -295,6 +295,10 @@ func (vsp *ViewSubmissionProcessor) ProcessCommand(callback slack.InteractionCal { vsp.incidentUpdateSeverityAction.IncidentUpdateSeverity(callback, request, callback.Channel, callback.User) } + case util.SeverityJustificationSubmit: + { + vsp.incidentUpdateSeverityAction.IncidentUpdateSeverityJustification(callback, request, callback.User) + } case util.SetIncidentTypeSubmit: { vsp.incidentUpdateTypeAction.IncidentUpdateType(callback, request, callback.Channel, callback.User) diff --git a/model/incident/incident.go b/model/incident/incident.go index 25ac41a..03f2fe9 100644 --- a/model/incident/incident.go +++ b/model/incident/incident.go @@ -193,20 +193,26 @@ func (r *Repository) processUserInfo() ([]byte, error) { return nil, fmt.Errorf("%s is not a valid user", valueAfterUpdate.UpdatedBy) } -func (r *Repository) captureLogs() { +func (r *Repository) captureLogs(justification string) { if differences != nil && len(differences) > 0 { jsonUser, _ := r.processUserInfo() jsonDiff := r.processDiffIds() - r.logRepository.CreateLog( - &log.LogEntity{ - CreatedAt: time.Now(), - RelationName: "incident", - RecordId: valueAfterUpdate.ID, - UserInfo: jsonUser, - Changes: jsonDiff, - }) + logEntity := log.LogEntity{ + CreatedAt: time.Now(), + RelationName: "incident", + RecordId: valueAfterUpdate.ID, + UserInfo: jsonUser, + Changes: jsonDiff, + } + if justification != "" { + logEntity.Justification = justification + } + _, err := r.logRepository.CreateLog(&logEntity) + if err != nil { + logger.Error(fmt.Sprintf("%d failed to create log. Error: %v", logEntity.RecordId, err)) + } differences = []utils.Difference{} } @@ -218,11 +224,20 @@ func (r *Repository) UpdateIncident(incidentEntity *IncidentEntity) error { return result.Error } - r.captureLogs() + r.captureLogs("") return nil } +func (r *Repository) UpdateIncidentWithJustification(incidentEntity *IncidentEntity, justification string) error { + result := r.gormClient.Updates(incidentEntity) + if result.Error != nil { + return result.Error + } + r.captureLogs(justification) + return nil +} + func (r *Repository) GetIncidentStatusByStatusName(status string) (*IncidentStatusEntity, error) { var incidentStatus IncidentStatusEntity diff --git a/model/incident/incident_repository_interface.go b/model/incident/incident_repository_interface.go index 5842f15..c7c1528 100644 --- a/model/incident/incident_repository_interface.go +++ b/model/incident/incident_repository_interface.go @@ -3,6 +3,7 @@ package incident type IIncidentRepository interface { CreateIncidentEntity(request *CreateIncidentDTO) (*IncidentEntity, error) UpdateIncident(incidentEntity *IncidentEntity) error + UpdateIncidentWithJustification(incidentEntity *IncidentEntity, justification string) error CreateIncidentTag(incidentId, tagId uint) (*IncidentTagEntity, error) CreateIncidentTagsInBatchesForAnIncident(incidentId uint, tagIds []uint) (*[]IncidentTagEntity, error) GetIncidentStatusByStatusName(status string) (*IncidentStatusEntity, error) diff --git a/model/log/entity.go b/model/log/entity.go index c3580d2..e2f54f8 100644 --- a/model/log/entity.go +++ b/model/log/entity.go @@ -6,11 +6,12 @@ import ( ) type LogEntity struct { - CreatedAt time.Time `gorm:"column:created_at"` - RelationName string `gorm:"column:relation_name"` - RecordId uint `gorm:"column:record_id"` - UserInfo datatypes.JSON `gorm:"column:user_info"` - Changes datatypes.JSON `gorm:"column:changes"` + CreatedAt time.Time `gorm:"column:created_at"` + RelationName string `gorm:"column:relation_name"` + RecordId uint `gorm:"column:record_id"` + UserInfo datatypes.JSON `gorm:"column:user_info"` + Changes datatypes.JSON `gorm:"column:changes"` + Justification string `gorm:"column:justification;default:NULL"` } func (LogEntity) TableName() string { diff --git a/service/incident/impl/incident_service_test.go b/service/incident/impl/incident_service_test.go index 77efcb8..7958c41 100644 --- a/service/incident/impl/incident_service_test.go +++ b/service/incident/impl/incident_service_test.go @@ -90,7 +90,7 @@ func (suite *IncidentServiceSuite) Test_UpdateIncident_Success() { suite.incidentRepository.UpdateIncidentMock.When(mockIncident). Then(nil) - + suite.incidentRepository.UpdateIncidentWithJustificationMock.When(mockIncident, "").Then(nil) suite.slackService.PostMessageByChannelIDMock.Return("", nil) suite.slackService.UpdateMessageWithAttachmentsMock.Return(nil) diff --git a/service/incident/impl/incident_service_v2.go b/service/incident/impl/incident_service_v2.go index 9697da2..71b3ffe 100644 --- a/service/incident/impl/incident_service_v2.go +++ b/service/incident/impl/incident_service_v2.go @@ -1337,13 +1337,28 @@ func (i *IncidentServiceV2) UpdateSeverityId( } if incidentEntity.SeverityId != uint(num) { - incidentEntity.SeverityId = uint(num) - incidentEntity.SeverityTat = time.Now().AddDate(0, 0, severityEntity.Sla) + if viper.GetBool("ENABLE_DE_ESCALATION_JUSTIFICATION") { + if uint(num) > incidentEntity.SeverityId && request.Justification == "" { + return fmt.Errorf("justification is required for de-escalating the incident") + } + incidentEntity.SeverityId = uint(num) + incidentEntity.SeverityTat = time.Now().AddDate(0, 0, severityEntity.Sla) + incidentEntity.UpdatedAt = time.Now() + incidentEntity.UpdatedBy = userId + err := i.incidentRepository.UpdateIncidentWithJustification(incidentEntity, request.Justification) + if err != nil { + logger.Error(fmt.Sprintf("%s error in committing update to DB", updateLogTag), zap.Error(err)) + return err + } + } else { + incidentEntity.SeverityId = uint(num) + incidentEntity.SeverityTat = time.Now().AddDate(0, 0, severityEntity.Sla) - err := i.commitIncidentEntity(incidentEntity, userId) - if err != nil { - logger.Error(fmt.Sprintf("%s error in committing update to DB", updateLogTag), zap.Error(err)) - return err + err := i.commitIncidentEntity(incidentEntity, userId) + if err != nil { + logger.Error(fmt.Sprintf("%s error in committing update to DB", updateLogTag), zap.Error(err)) + return err + } } err = i.UpdateSeverityWorkflow( @@ -1353,6 +1368,15 @@ func (i *IncidentServiceV2) UpdateSeverityId( logger.Error(fmt.Sprintf("%s error in update severity workflow", updateLogTag), zap.Error(err)) return err } + if request.Justification != "" { + go func() { + _, err := i.slackService.PostMessageByChannelID(fmt.Sprintf("`Justification for reducing the severity`: %s", + request.Justification), false, incidentEntity.SlackChannel) + if err != nil { + logger.Error(fmt.Sprintf("%s error in posting justification message", updateLogTag), zap.Error(err)) + } + }() + } go i.SendAlert(incidentEntity) } } diff --git a/service/request/update_incident.go b/service/request/update_incident.go index eeef7fe..a14943e 100644 --- a/service/request/update_incident.go +++ b/service/request/update_incident.go @@ -9,4 +9,5 @@ type UpdateIncidentRequest struct { SeverityId string `json:"severityId,omitempty"` MetaData incident.CreateIncidentMetaData `json:"metaData,omitempty"` DuplicateOfId uint `json:"duplicateOfId,omitempty"` + Justification string `json:"justification,omitempty"` } diff --git a/service/response/log_response.go b/service/response/log_response.go index 7473f45..621c34d 100644 --- a/service/response/log_response.go +++ b/service/response/log_response.go @@ -14,9 +14,10 @@ type LogResponse struct { } type LogEntry struct { - CreatedAt time.Time `json:"created_at"` - UserInfo datatypes.JSON `json:"user_info"` - Changes datatypes.JSON `json:"changes"` + CreatedAt time.Time `json:"created_at"` + UserInfo datatypes.JSON `json:"user_info"` + Changes datatypes.JSON `json:"changes"` + Justification string `json:"justification,omitempty"` } func ConvertToLogResponse(logEnties []log.LogEntity, relationName string, recordId uint, hasCreation bool) LogResponse { @@ -24,9 +25,10 @@ func ConvertToLogResponse(logEnties []log.LogEntity, relationName string, record for index := range logEnties { logEntry := LogEntry{ - CreatedAt: logEnties[index].CreatedAt, - UserInfo: logEnties[index].UserInfo, - Changes: logEnties[index].Changes, + CreatedAt: logEnties[index].CreatedAt, + UserInfo: logEnties[index].UserInfo, + Changes: logEnties[index].Changes, + Justification: logEnties[index].Justification, } logs = append(logs, logEntry) }