Skip to content

Instantly share code, notes, and snippets.

@lawrencejones
Last active September 14, 2022 03:16
Show Gist options
  • Save lawrencejones/b233d78686f63a753f8ff3e3479bb433 to your computer and use it in GitHub Desktop.
Save lawrencejones/b233d78686f63a753f8ff3e3479bb433 to your computer and use it in GitHub Desktop.
Slack modal to escalate to Splunk On-Call

Slack modal to escalate to Splunk On-Call

This is the code that implements a Slack modal for escalating to Splunk On-Call.

Thanks to some investment in our Slack tooling, we have a framework for building modals that makes them really easy to build, and trivially testable.

Just by adding these two files, these modals become useable. Simples!

Screenshot of the modal

package views
import (
"context"
"fmt"
"github.com/incident-io/core/server/api/gen/common"
"github.com/incident-io/core/server/api/gen/escalations"
"github.com/incident-io/core/server/pkg/domain"
"github.com/incident-io/core/server/pkg/errors"
"github.com/incident-io/core/server/pkg/log"
"github.com/incident-io/core/server/pkg/partial"
"github.com/incident-io/core/server/pkg/rbac"
"github.com/incident-io/core/server/pkg/service/escalatorv2"
"github.com/incident-io/core/server/pkg/slackv2/components"
"github.com/incident-io/core/server/pkg/slackv2/fixtures"
slackpreview "github.com/incident-io/core/server/pkg/slackv2/preview"
"github.com/incident-io/core/server/pkg/slackv2/typeaheads"
"github.com/slack-go/slack"
"gorm.io/gorm"
"github.com/incident-io/core/server/pkg/slackv2/views/modal"
. "github.com/incident-io/core/server/pkg/slackv2/views/modal"
)
func init() {
Register(func(render func(modal EscalateSplunkOnCall, props EscalateSplunkOnCallProps, state EscalateSplunkOnCallState) *slack.ModalViewRequest) slackpreview.Template {
buildProps := func(incTweaks partial.Partial[domain.Incident]) EscalateSplunkOnCallProps {
props := EscalateSplunkOnCallProps{
Incident: incTweaks.Apply(*fixtures.IncidentStandard),
}
return props
}
return slackpreview.Template{
Name: "Modal: Escalate: Splunk On-Call",
Description: "Escalate to Splunk On-Call",
Variants: []slackpreview.Variant{
{
Name: "default",
BlockKit: render(
EscalateSplunkOnCall{},
buildProps(domain.IncidentBuilder()),
EscalateSplunkOnCallState{},
),
},
{
Name: "private incident",
BlockKit: render(
EscalateSplunkOnCall{},
buildProps(domain.IncidentBuilder(
domain.IncidentBuilder.IsPrivate(true),
)),
EscalateSplunkOnCallState{},
),
},
},
}
})
}
type EscalateSplunkOnCall struct {
IncidentID string `json:"incident_id"`
IdempotencyKey string `json:"idempotency_key"`
}
type EscalateSplunkOnCallProps struct {
Incident *domain.Incident
}
type EscalateSplunkOnCallState struct {
Description *string `modal:"description.value"`
Teams *[]slack.OptionBlockObject `modal:"teams.value"`
Policies *[]slack.OptionBlockObject `modal:"policies.value"`
Users *[]slack.OptionBlockObject `modal:"users.value"`
}
func (m EscalateSplunkOnCall) CallbackID() string {
return "EscalateSplunkOnCall"
}
func (m EscalateSplunkOnCall) DefaultState() *EscalateSplunkOnCallState {
return &EscalateSplunkOnCallState{}
}
func (m EscalateSplunkOnCall) BuildProps(ctx context.Context, db *gorm.DB, identity *rbac.Identity, state *EscalateSplunkOnCallState) (EscalateSplunkOnCallProps, error) {
inc, err := GetIncident(ctx, db, identity.Organisation, m.IncidentID)
if err != nil {
return EscalateSplunkOnCallProps{}, err
}
return EscalateSplunkOnCallProps{
Incident: inc,
}, nil
}
func (m EscalateSplunkOnCall) OnAction(ctx context.Context, db *gorm.DB, identity *rbac.Identity, deps Dependencies, props EscalateSplunkOnCallProps, state *EscalateSplunkOnCallState, cb slack.InteractionCallback) (bool, error) {
return false, nil
}
func (m EscalateSplunkOnCall) OnSubmit(ctx context.Context, db *gorm.DB, identity *rbac.Identity, deps Dependencies, props EscalateSplunkOnCallProps, state *EscalateSplunkOnCallState) (resp *slack.ViewSubmissionResponse, err error) {
if state.Policies == nil {
state.Policies = new([]slack.OptionBlockObject)
}
if state.Teams == nil {
state.Teams = new([]slack.OptionBlockObject)
}
if state.Users == nil {
state.Users = new([]slack.OptionBlockObject)
}
if len(*state.Policies)+len(*state.Teams)+len(*state.Users) == 0 {
return nil, errors.InvalidValue(
"policies", errors.New("Must provide at least one policy, team or user"),
)
}
targets := []*common.EscalationTarget{}
for _, policy := range *state.Policies {
targets = append(targets, &common.EscalationTarget{
Type: "EscalationPolicy",
ID: policy.Value,
})
}
for _, team := range *state.Teams {
targets = append(targets, &common.EscalationTarget{
Type: "Team",
ID: team.Value,
})
}
for _, user := range *state.Users {
targets = append(targets, &common.EscalationTarget{
Type: "User",
ID: user.Value,
})
}
payload := &escalations.CreatePayload{
IncidentID: m.IncidentID,
IdempotencyKey: &m.IdempotencyKey,
Provider: string(domain.EscalationProviderSplunkOnCall),
Title: &props.Incident.Name,
Description: state.Description,
Targets: targets,
}
log.Info(ctx, "Creating escalation with payload", map[string]any{
"escalation_payload": payload,
})
escalation, err := escalatorv2.Create(ctx, db, deps.EventService, identity, payload)
if err != nil {
return nil, errors.Wrap(err, "creating escalation")
}
log.Info(ctx, "Created escalation", map[string]any{
"escalation": escalation.ID,
})
return nil, nil // close modal
}
func (m EscalateSplunkOnCall) Render(props EscalateSplunkOnCallProps, state *EscalateSplunkOnCallState, isFirstRender bool) ModalViewRequest {
blocks := []slack.Block{}
// Why do you need them?
{
element := slack.NewPlainTextInputBlockElement(components.TextPlain("The database is having issues"), "value")
if props.Incident.Summary.Valid && props.Incident.Summary.String != "" {
element.InitialValue = props.Incident.Summary.String
} else {
element.InitialValue = fmt.Sprintf("Escalating from incident %s", props.Incident.Name)
}
block := slack.NewInputBlock("description", components.TextPlain("Why do you need them?"), element)
block.Hint = components.TextPlain(
"This will be what they see/hear when Splunk On-Call contacts them. Make it something a robot can pronounce!")
blocks = append(blocks, block)
}
// Which policies?
{
element := slack.NewOptionsMultiSelectBlockElement(slack.MultiOptTypeExternal, nil, "value")
element.MinQueryLength = new(int) // set this to zero so we get options without needing to type anything
block := slack.NewInputBlock(
"policies", components.TextPlain("Which policies?"), element)
block.Optional = true
block.Hint = components.TextPlain("Pick an escalation policy to decide who is paged.")
blockWithSuggestion := modal.SuggestionHandler(block,
func(ctx context.Context, db *gorm.DB, identity *rbac.Identity, searchString string) ([]*slack.OptionBlockObject, error) {
return typeaheads.SplunkOnCallEscalationPolicies(ctx, db, identity.Organisation, nil, searchString)
})
blocks = append(blocks, blockWithSuggestion)
}
// Which teams?
{
element := slack.NewOptionsMultiSelectBlockElement(slack.MultiOptTypeExternal, nil, "value")
element.MinQueryLength = new(int) // set this to zero so we get options without needing to type anything
block := slack.NewInputBlock(
"teams", components.TextPlain("Which teams?"), element)
block.Optional = true
block.Hint = components.TextPlain("Or choose a team to page directly.")
blockWithSuggestion := SuggestionHandler(block,
func(ctx context.Context, db *gorm.DB, identity *rbac.Identity, searchString string) ([]*slack.OptionBlockObject, error) {
return typeaheads.SplunkOnCallTeams(ctx, db, identity.Organisation, nil, searchString)
})
blocks = append(blocks, blockWithSuggestion)
}
// Which users?
{
element := slack.NewOptionsMultiSelectBlockElement(slack.MultiOptTypeExternal, nil, "value")
element.MinQueryLength = new(int) // set this to zero so we get options without needing to type anything
block := slack.NewInputBlock(
"users", components.TextPlain("Who do you need?"), element)
block.Optional = true
block.Hint = components.TextPlain("Or a selection of users, if you need someone specific.")
blockWithSuggestion := modal.SuggestionHandler(block,
func(ctx context.Context, db *gorm.DB, identity *rbac.Identity, searchString string) ([]*slack.OptionBlockObject, error) {
return typeaheads.SplunkOnCallUsers(ctx, db, identity.Organisation, nil, searchString)
})
blocks = append(blocks, blockWithSuggestion)
}
return ModalViewRequest{
Title: components.TextPlain("Escalate"),
Close: components.TextPlain("Cancel"),
Submit: components.TextPlain("Escalate"),
Blocks: slack.Blocks{
BlockSet: blocks,
},
}
}
package views
import (
"context"
"time"
"github.com/golang/mock/gomock"
"github.com/incident-io/core/server/api/gen/escalations"
"github.com/incident-io/core/server/pkg/domain"
"github.com/incident-io/core/server/pkg/domain/factories"
"github.com/incident-io/core/server/pkg/errors"
"github.com/incident-io/core/server/pkg/rbac"
"github.com/incident-io/core/server/pkg/service/escalatorv2"
mock_escalatorv2 "github.com/incident-io/core/server/pkg/service/escalatorv2/mock"
"github.com/incident-io/core/server/pkg/service/event"
"github.com/incident-io/core/server/pkg/slackv2/views/modal"
"github.com/incident-io/core/server/pkg/spec"
"github.com/samber/lo"
"github.com/slack-go/slack"
"gopkg.in/guregu/null.v3"
"gorm.io/gorm"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("EscalateSplunkOnCall", func() {
var (
subject EscalateSplunkOnCall
org domain.Organisation
resources factories.OrganisationResources
inc *domain.Incident
)
// Setup database resources.
BeforeEach(func() {
org, resources = spec.CreateOrganisationWithDefaults(ctx, tx)
inc = spec.Create(ctx, tx, factories.IncidentWithDefaults(resources))
})
var (
state *EscalateSplunkOnCallState
deps modal.Dependencies
)
// Reset modal parameters.
BeforeEach(func() {
subject = EscalateSplunkOnCall{
IncidentID: inc.ID,
IdempotencyKey: "idempotency-key",
}
state = subject.DefaultState()
deps = modal.Dependencies{
EventService: event.NewTestService(GinkgoT()),
}
})
defaultProps := func() EscalateSplunkOnCallProps {
return EscalateSplunkOnCallProps{
Incident: inc,
}
}
Describe("BuildProps", func() {
var (
props EscalateSplunkOnCallProps
err error
)
JustBeforeEach(func() {
props, err = subject.BuildProps(ctx, tx, resources.Identity, state)
})
// Confirm that for all our tests, what we'd render with the resulting props is valid
// for the state.
//
// This ensures BuildProps and Render agree with one another.
AfterEach(func() {
if err != nil {
return
}
err := modal.CheckBlocks[EscalateSplunkOnCallState](subject.Render(props, state, true).Blocks.BlockSet)
Expect(err).NotTo(HaveOccurred(), "tests produced blocks that don't match state")
})
It("succeeds", func() {
Expect(err).NotTo(HaveOccurred())
})
It("loads the incident", func() {
Expect(props.Incident).To(domain.IncidentMatcher(
domain.IncidentMatcher.ID(inc.ID),
))
})
Context("without an incident", func() {
BeforeEach(func() {
subject.IncidentID = ""
})
It("errors", func() {
Expect(err).To(HaveOccurred())
})
})
})
Describe("Render", func() {
var (
props EscalateSplunkOnCallProps
req modal.ModalViewRequest
)
BeforeEach(func() {
props = defaultProps()
})
JustBeforeEach(func() {
req = subject.Render(props, state, true)
})
// Confirm all test blocks are compatible with the state.
AfterEach(func() {
err := modal.CheckBlocks[EscalateSplunkOnCallState](req.Blocks.BlockSet)
Expect(err).NotTo(HaveOccurred(), "tests produced blocks that don't match state")
})
Describe("description input", func() {
Context("when incident has no summary", func() {
BeforeEach(func() {
inc.Summary = null.String{}
})
It("sets initial value to sensible default", func() {
block := GetBlock[*slack.InputBlock](req, "description")
Expect(block.Element.(*slack.PlainTextInputBlockElement).InitialValue).To(
Equal("Escalating from incident butter-owl"),
)
})
})
Context("when incident has a summary", func() {
BeforeEach(func() {
inc.Summary = null.StringFrom("It's getting hot in here!")
})
It("sets initial value to the incident summary", func() {
block := GetBlock[*slack.InputBlock](req, "description")
Expect(block.Element.(*slack.PlainTextInputBlockElement).InitialValue).To(
Equal("It's getting hot in here!"),
)
})
})
})
})
Describe("OnSubmit", func() {
var (
props EscalateSplunkOnCallProps
resp *slack.ViewSubmissionResponse
ctrl *gomock.Controller
escalator *mock_escalatorv2.MockEscalator
err error
)
BeforeEach(func() { props = defaultProps() })
// Configure and override the context with a mock escalator
BeforeEach(func() {
ctrl = gomock.NewController(GinkgoT())
escalator = mock_escalatorv2.NewMockEscalator(ctrl)
ctx = escalatorv2.WithEscalator(ctx, escalator)
// Expect a call to the mock escalator requesting to create an escalation.
escalator.EXPECT().
Create(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, db *gorm.DB, identity *rbac.Identity, payload *escalations.CreatePayload) (*domain.EscalationExternal, error) {
result := &domain.EscalationExternal{
Status: domain.EscalationStatusTriggered,
Entries: []*domain.EscalationLogEntry{
{
OccurredAt: time.Now(),
Type: domain.EscalationLogEntryTypePage,
Targets: []*domain.EscalationTarget{
{
Type: "User",
ID: "lawrencejones",
},
},
},
},
}
return result, nil
}).
Times(1)
})
// Ensure the mock was called correctly.
AfterEach(func() {
if err == nil {
ctrl.Finish()
}
})
JustBeforeEach(func() {
resp, err = subject.OnSubmit(ctx, tx, resources.Identity, deps, props, state)
})
Context("with values selected", func() {
BeforeEach(func() {
state = &EscalateSplunkOnCallState{
Description: lo.ToPtr("Help needed with the database"),
Users: lo.ToPtr([]slack.OptionBlockObject{
{
Value: "lawrencejones",
},
}),
}
})
It("succeeds and closes modal", func() {
Expect(err).To(BeNil())
Expect(resp).To(BeNil()) // nil closes the modal
})
It("creates escalation", func() {
escalation := spec.FirstBy(ctx, tx, domain.EscalationBuilder(
domain.EscalationBuilder.OrganisationID(org.ID),
))
Expect(escalation).To(domain.EscalationMatcher(
domain.EscalationMatcher.Title(inc.Name),
domain.EscalationMatcher.Description("Help needed with the database"),
domain.EscalationMatcher.IdempotencyKey("idempotency-key"),
))
})
})
Context("with an invalid input", func() {
BeforeEach(func() {
state = &EscalateSplunkOnCallState{
Users: nil,
}
})
It("returns error", func() {
Expect(err).NotTo(BeNil())
validationError, ok := errors.Asg[*errors.Validation](err)
Expect(ok).To(BeTrue(), "expected error to be a validation error")
Expect(validationError.FieldErrors).To(ContainElement(
errors.FieldErrorMatcher(
errors.FieldErrorMatcher.FieldKey("policies"),
errors.FieldErrorMatcher.Match().Cause(MatchError(
"Must provide at least one policy, team or user",
)),
),
))
})
})
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment