Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/domain/entity/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ type Alert struct {
// ResolvedAt is when the alert was resolved.
ResolvedAt *time.Time

// ResolvedBy identifies who manually resolved the alert (from Slack).
ResolvedBy string

// CreatedAt is when this record was created.
CreatedAt time.Time

Expand Down Expand Up @@ -129,6 +132,14 @@ func (a *Alert) Resolve(at time.Time) {
a.UpdatedAt = at
}

// ResolveBy marks the alert as manually resolved by a specific user.
func (a *Alert) ResolveBy(by string, at time.Time) {
a.State = StateResolved
a.ResolvedAt = &at
a.ResolvedBy = by
a.UpdatedAt = at
}

// IsActive returns true if the alert is in active state.
func (a *Alert) IsActive() bool {
return a.State == StateActive
Expand Down
131 changes: 103 additions & 28 deletions internal/infrastructure/slack/message_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,28 +80,35 @@

// BuildAlertMessage creates a Block Kit message for an alert.
func (b *MessageBuilder) BuildAlertMessage(alert *entity.Alert) []slack.Block {
return b.buildMessage(alert, true, true, nil)
return b.buildMessage(alert, true, true, false, nil)
}

// BuildAlertMessageWithMentions creates a Block Kit message for an alert
// with subscriber mentions at the top.
func (b *MessageBuilder) BuildAlertMessageWithMentions(alert *entity.Alert, slackUserIDs []string) []slack.Block {
return b.buildMessage(alert, true, true, slackUserIDs)
return b.buildMessage(alert, true, true, false, slackUserIDs)
}

// BuildAckedMessage creates a message for an acknowledged alert with silence button still available.
// BuildAckedMessage creates a message for an acknowledged alert with Resolve button.
func (b *MessageBuilder) BuildAckedMessage(alert *entity.Alert) []slack.Block {
return b.buildMessage(alert, false, true, nil)
return b.buildMessage(alert, false, true, true, nil)
}

// BuildResolvedMessage creates a message for a resolved alert (no buttons).
func (b *MessageBuilder) BuildResolvedMessage(alert *entity.Alert) []slack.Block {
return b.buildMessage(alert, false, false, nil)
return b.buildMessage(alert, false, false, false, nil)
}

// buildMessage creates a clean, bright Block Kit message with configurable button options.
// buildMessage creates a clean, structured Block Kit message with configurable button options.
// The layout follows:
// - Status header with emoji and severity (e.g., "🟢 RESOLVED | WARNING")
// - Alert name
// - Summary/description
// - Details fields (Instance, Target, ID)
// - Action buttons
// - Timeline footer
// slackUserIDs is optional - if provided, mentions will be added at the top of the message.
func (b *MessageBuilder) buildMessage(alert *entity.Alert, showAckButton, showSilenceButton bool, slackUserIDs []string) []slack.Block {
func (b *MessageBuilder) buildMessage(alert *entity.Alert, showAckButton, showSilenceButton, showResolveButton bool, slackUserIDs []string) []slack.Block {
var blocks []slack.Block

// Add user mentions section at the top if any subscribers matched
Expand All @@ -114,37 +121,77 @@
))
}

// Clean header with emoji + name
emoji, _, _ := b.getStatusInfo(alert)
headerText := fmt.Sprintf("%s %s", emoji, alert.Name)
// Status header with emoji and severity badge
// Format: "🟢 RESOLVED | WARNING" or "🔴 FIRING | CRITICAL"
emoji, statusText, _ := b.getStatusInfo(alert)
severityBadge := strings.ToUpper(string(alert.Severity))
statusHeader := fmt.Sprintf("%s *%s* | %s", emoji, statusText, severityBadge)
blocks = append(blocks, slack.NewSectionBlock(

Check failure on line 129 in internal/infrastructure/slack/message_builder.go

View workflow job for this annotation

GitHub Actions / golangci-lint

appendCombine: can combine chain of 2 appends into one (gocritic)
slack.NewTextBlockObject(slack.MarkdownType, statusHeader, false, false),
nil, nil,
))

// Alert name as header
blocks = append(blocks, slack.NewHeaderBlock(
slack.NewTextBlockObject(slack.PlainTextType, headerText, true, false),
slack.NewTextBlockObject(slack.PlainTextType, alert.Name, true, false),
))

// Summary (if available) - light and simple
// Summary/description (if available)
if alert.Summary != "" {
blocks = append(blocks, slack.NewSectionBlock(
slack.NewTextBlockObject(slack.MarkdownType, alert.Summary, false, false),
nil, nil,
))
}

// Compact info line
blocks = append(blocks, b.buildCompactInfo(alert))
// Details fields section
blocks = append(blocks, b.buildDetailsFields(alert))

// Action buttons (configurable)
if showAckButton || showSilenceButton {
if actionBlock := b.buildActionButtons(alert.ID, showAckButton, showSilenceButton); actionBlock != nil {
if showAckButton || showSilenceButton || showResolveButton {
if actionBlock := b.buildActionButtons(alert.ID, showAckButton, showSilenceButton, showResolveButton); actionBlock != nil {
blocks = append(blocks, actionBlock)
}
}

// Subtle footer
// Timeline footer
blocks = append(blocks, b.buildTimelineContext(alert))

return blocks
}

// buildDetailsFields creates field blocks for Instance, Target, and ID.
func (b *MessageBuilder) buildDetailsFields(alert *entity.Alert) *slack.SectionBlock {
var fields []*slack.TextBlockObject

// Instance
if alert.Instance != "" {
fields = append(fields,
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("*Instance*\n`%s`", alert.Instance), false, false))
}

// Target
if alert.Target != "" {
fields = append(fields,
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("*Target*\n`%s`", alert.Target), false, false))
}

// Fingerprint/ID (shortened for display)
if alert.Fingerprint != "" {
fp := alert.Fingerprint
if len(fp) > 12 {
fp = fp[:12] + "..."
}
fields = append(fields,
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("*ID*\n`%s`", fp), false, false))
}

return slack.NewSectionBlock(nil, fields, nil)
}

// buildCompactInfo creates a single-line info display - clean and minimal.
func (b *MessageBuilder) buildCompactInfo(alert *entity.Alert) *slack.ContextBlock {
var elements []slack.MixedElement
Expand Down Expand Up @@ -226,28 +273,45 @@
return slack.NewSectionBlock(nil, fields, nil)
}

// buildTimelineContext creates a clean, minimal footer.
// buildTimelineContext creates a clean, minimal footer with timeline information.
// Uses Slack's date formatting for automatic timezone/locale conversion.
// Format: "Fired: Jan 22, 10:31 • Acked: 10:45 by user • Resolved: 10:51 by user"
func (b *MessageBuilder) buildTimelineContext(alert *entity.Alert) *slack.ContextBlock {
var elements []slack.MixedElement
var parts []string

// Fired time - uses Slack date formatting for automatic timezone conversion
firedAt := FormatSlackTime(alert.FiredAt, SlackDateShort)
elements = append(elements,
slack.NewTextBlockObject(slack.MarkdownType, firedAt, false, false))
parts = append(parts, fmt.Sprintf("*Fired:* %s", firedAt))

// Acknowledged info
if alert.IsAcked() && alert.AckedAt != nil {
ackedAt := FormatSlackTime(*alert.AckedAt, SlackTimeOnly)
if alert.AckedBy != "" {
parts = append(parts, fmt.Sprintf("*Acked:* %s by %s", ackedAt, alert.AckedBy))
} else {
parts = append(parts, fmt.Sprintf("*Acked:* %s", ackedAt))
}
}

// Acknowledged by
if alert.IsAcked() && alert.AckedBy != "" {
elements = append(elements,
slack.NewTextBlockObject(slack.MarkdownType,
fmt.Sprintf("by %s", alert.AckedBy), false, false))
// Resolved info
if alert.IsResolved() && alert.ResolvedAt != nil {
resolvedAt := FormatSlackTime(*alert.ResolvedAt, SlackTimeOnly)
if alert.ResolvedBy != "" {
parts = append(parts, fmt.Sprintf("*Resolved:* %s by %s", resolvedAt, alert.ResolvedBy))
} else {
parts = append(parts, fmt.Sprintf("*Resolved:* %s", resolvedAt))
}
}

return slack.NewContextBlock("", elements...)
// Join with bullet separator
timelineText := strings.Join(parts, " • ")

return slack.NewContextBlock("",
slack.NewTextBlockObject(slack.MarkdownType, timelineText, false, false))
}

// buildActionButtons creates action buttons.
func (b *MessageBuilder) buildActionButtons(alertID string, showAck, showSilence bool) *slack.ActionBlock {
func (b *MessageBuilder) buildActionButtons(alertID string, showAck, showSilence, showResolve bool) *slack.ActionBlock {
var elements []slack.BlockElement

// Acknowledge button
Expand All @@ -260,6 +324,17 @@
elements = append(elements, ackBtn)
}

// Resolve button (shown after ack, replaces ack button)
if showResolve {
resolveBtn := slack.NewButtonBlockElement(
fmt.Sprintf("resolve_%s", alertID),
alertID,
slack.NewTextBlockObject(slack.PlainTextType, "Resolve", true, false),
)
resolveBtn.Style = slack.StylePrimary
elements = append(elements, resolveBtn)
}

// Silence dropdown
if showSilence {
options := make([]*slack.OptionBlockObject, len(b.silenceDurations))
Expand Down
89 changes: 88 additions & 1 deletion internal/usecase/slack/handle_interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
return uc.handleAck(ctx, alertID, input, userEmail)
case "silence":
return uc.handleSilence(ctx, alertID, input, userEmail)
case "resolve":
return uc.handleResolve(ctx, alertID, input, userEmail)
default:
return nil, fmt.Errorf("unknown action type: %s", actionType)
}
Expand Down Expand Up @@ -171,10 +173,12 @@
}

// Post thread reply about silence
// Uses Slack's date formatting for automatic timezone conversion per user
silenceEndAt := slackInfra.FormatSlackTime(silence.EndAt, slackInfra.SlackDateShort)
silenceMsg := fmt.Sprintf("🔕 Silenced for %s by %s (until %s)",
formatDuration(duration),
input.UserName,
silence.EndAt.Format("Jan 2, 15:04 MST"),
silenceEndAt,
)
if err := uc.slackClient.PostThreadReply(ctx, messageID, silenceMsg); err != nil {
uc.logger.Error("failed to post silence notification",
Expand All @@ -191,6 +195,89 @@
}, nil
}

// handleResolve handles the manual resolve action from Slack.
// This action:
// 1. Marks the alert as resolved (with who resolved it)
// 2. Cancels any active silences for this alert
// 3. Updates the Slack message to show resolved state
func (uc *HandleInteractionUseCase) handleResolve(ctx context.Context, alertID string, input dto.SlackInteractionInput, userEmail string) (*dto.SlackInteractionOutput, error) {

Check failure on line 203 in internal/usecase/slack/handle_interaction.go

View workflow job for this annotation

GitHub Actions / golangci-lint

(*HandleInteractionUseCase).handleResolve - userEmail is unused (unparam)
// Load the alert
alertEntity, err := uc.alertRepo.FindByID(ctx, alertID)
if err != nil {
return nil, fmt.Errorf("finding alert: %w", err)
}
if alertEntity == nil {
return nil, entity.ErrAlertNotFound
}

// Check if already resolved
if alertEntity.IsResolved() {
return &dto.SlackInteractionOutput{
Success: true,
Message: "Alert is already resolved",
}, nil
}

// Mark the alert as resolved by this user
now := time.Now().UTC()
alertEntity.ResolveBy(input.UserName, now)

// Save the alert
if err := uc.alertRepo.Update(ctx, alertEntity); err != nil {
return nil, fmt.Errorf("updating alert: %w", err)
}

// Cancel any active silences for this alert's fingerprint
silences, err := uc.silenceRepo.FindByFingerprint(ctx, alertEntity.Fingerprint)
if err != nil {
uc.logger.Warn("failed to find active silences",
"fingerprint", alertEntity.Fingerprint,
"error", err,
)
} else {
for _, silence := range silences {
if silence.IsActive() {
silence.Cancel()
if err := uc.silenceRepo.Update(ctx, silence); err != nil {
uc.logger.Warn("failed to cancel silence",
"silenceID", silence.ID,
"error", err,
)
}
}
}
}

// Update Slack message to show resolved state
messageID := fmt.Sprintf("%s:%s", input.ChannelID, input.MessageTS)
if err := uc.slackClient.UpdateMessage(ctx, messageID, alertEntity); err != nil {
uc.logger.Error("failed to update Slack message",
"messageID", messageID,
"error", err,
)
}

// Post thread reply about resolution
resolvedAt := slackInfra.FormatSlackTime(now, slackInfra.SlackTimeOnly)
resolveMsg := fmt.Sprintf("✅ Resolved by %s at %s", input.UserName, resolvedAt)
if err := uc.slackClient.PostThreadReply(ctx, messageID, resolveMsg); err != nil {
uc.logger.Error("failed to post resolve notification",
"messageID", messageID,
"error", err,
)
}

uc.logger.Info("alert manually resolved from Slack",
"alertID", alertID,
"resolvedBy", input.UserName,
)

return &dto.SlackInteractionOutput{
Success: true,
Message: fmt.Sprintf("Alert resolved by %s", input.UserName),
}, nil
}

// parseActionID parses an action ID like "ack_<alertID>" into action type and alert ID.
func parseActionID(actionID string) (actionType, alertID string) {
parts := strings.SplitN(actionID, "_", 2)
Expand Down
Loading