upaas/internal/service/notify/notify.go
sneak c4362c3143 Add commit URL to Slack notifications with link and backtick formatting
- Add commit_url column to webhook_events and deployments tables
- Extract commit URL from webhook payload (from commit object or repo URL)
- Format Slack messages with backticks for branch and commit SHA
- Link commit SHA to the actual commit URL on the git server
- Keep plain text format for ntfy notifications
2025-12-31 16:29:22 -08:00

370 lines
8.7 KiB
Go

// Package notify provides notification services.
package notify
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
// HTTP client timeout.
const (
httpClientTimeout = 10 * time.Second
)
// HTTP status code thresholds.
const (
httpStatusClientError = 400
)
// Display constants.
const (
shortCommitLength = 12
secondsPerMinute = 60
)
// Sentinel errors for notification failures.
var (
// ErrNtfyFailed indicates the ntfy notification request failed.
ErrNtfyFailed = errors.New("ntfy notification failed")
// ErrSlackFailed indicates the Slack notification request failed.
ErrSlackFailed = errors.New("slack notification failed")
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
}
// Service provides notification functionality.
type Service struct {
log *slog.Logger
client *http.Client
params *ServiceParams
}
// New creates a new notify Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
client: &http.Client{
Timeout: httpClientTimeout,
},
params: &params,
}, nil
}
// NotifyBuildStart sends a build started notification.
func (svc *Service) NotifyBuildStart(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
title := "Build started: " + app.Name
// Plain text message for ntfy
ntfyMessage := "Building from branch " + app.Branch
if deployment.CommitSHA.Valid {
shortSHA := truncateSHA(deployment.CommitSHA.String)
ntfyMessage += " at " + shortSHA
}
// Slack message with formatting
slackMessage := "Building from branch `" + app.Branch + "`"
if deployment.CommitSHA.Valid {
shortSHA := truncateSHA(deployment.CommitSHA.String)
slackMessage += " at " + formatCommitLink(shortSHA, deployment.CommitURL)
}
svc.sendNotifications(ctx, app, title, ntfyMessage, slackMessage, "info")
}
// NotifyBuildSuccess sends a build success notification.
func (svc *Service) NotifyBuildSuccess(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
duration := time.Since(deployment.StartedAt)
title := "Build success: " + app.Name
message := "Image built successfully in " + formatDuration(duration)
svc.sendNotifications(ctx, app, title, message, message, "success")
}
// NotifyBuildFailed sends a build failed notification.
func (svc *Service) NotifyBuildFailed(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
buildErr error,
) {
duration := time.Since(deployment.StartedAt)
title := "Build failed: " + app.Name
message := "Build failed after " + formatDuration(duration) + ": " + buildErr.Error()
svc.sendNotifications(ctx, app, title, message, message, "error")
}
// NotifyDeploySuccess sends a deploy success notification.
func (svc *Service) NotifyDeploySuccess(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy success: " + app.Name
// Plain text message for ntfy
ntfyMessage := "Successfully deployed in " + formatDuration(duration)
if deployment.CommitSHA.Valid {
shortSHA := truncateSHA(deployment.CommitSHA.String)
ntfyMessage += " (commit " + shortSHA + ")"
}
// Slack message with formatting
slackMessage := "Successfully deployed in " + formatDuration(duration)
if deployment.CommitSHA.Valid {
shortSHA := truncateSHA(deployment.CommitSHA.String)
slackMessage += " (commit " + formatCommitLink(shortSHA, deployment.CommitURL) + ")"
}
svc.sendNotifications(ctx, app, title, ntfyMessage, slackMessage, "success")
}
// NotifyDeployFailed sends a deploy failed notification.
func (svc *Service) NotifyDeployFailed(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
deployErr error,
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy failed: " + app.Name
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, message, "error")
}
// formatDuration formats a duration for display.
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
minutes := int(d.Minutes())
seconds := int(d.Seconds()) % secondsPerMinute
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
// truncateSHA truncates a commit SHA to shortCommitLength characters.
func truncateSHA(sha string) string {
if len(sha) > shortCommitLength {
return sha[:shortCommitLength]
}
return sha
}
// formatCommitLink formats a commit SHA as a Slack link if URL is available.
func formatCommitLink(shortSHA string, commitURL sql.NullString) string {
if commitURL.Valid && commitURL.String != "" {
return "<" + commitURL.String + "|`" + shortSHA + "`>"
}
return "`" + shortSHA + "`"
}
func (svc *Service) sendNotifications(
ctx context.Context,
app *models.App,
title, ntfyMessage, slackMessage, priority string,
) {
// Send to ntfy if configured
if app.NtfyTopic.Valid && app.NtfyTopic.String != "" {
ntfyTopic := app.NtfyTopic.String
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure notification completes
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, ntfyMessage, priority)
if ntfyErr != nil {
svc.log.Error(
"failed to send ntfy notification",
"error", ntfyErr,
"app", appName,
)
}
}()
}
// Send to Slack if configured
if app.SlackWebhook.Valid && app.SlackWebhook.String != "" {
slackWebhook := app.SlackWebhook.String
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure notification completes
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, slackMessage, priority)
if slackErr != nil {
svc.log.Error(
"failed to send slack notification",
"error", slackErr,
"app", appName,
)
}
}()
}
}
func (svc *Service) sendNtfy(
ctx context.Context,
topic, title, message, priority string,
) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
topic,
bytes.NewBufferString(message),
)
if err != nil {
return fmt.Errorf("failed to create ntfy request: %w", err)
}
request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf("%w: status %d", ErrNtfyFailed, resp.StatusCode)
}
return nil
}
func (svc *Service) ntfyPriority(priority string) string {
switch priority {
case "error":
return "urgent"
case "success":
return "default"
case "info":
return "low"
default:
return "default"
}
}
func (svc *Service) slackColor(priority string) string {
switch priority {
case "error":
return "#dc3545" // red
case "success":
return "#28a745" // green
case "info":
return "#17a2b8" // blue
default:
return "#6c757d" // gray
}
}
// SlackPayload represents a Slack webhook payload.
type SlackPayload struct {
Text string `json:"text"`
Attachments []SlackAttachment `json:"attachments,omitempty"`
}
// SlackAttachment represents a Slack attachment.
type SlackAttachment struct {
Color string `json:"color"`
Title string `json:"title"`
Text string `json:"text"`
}
func (svc *Service) sendSlack(
ctx context.Context,
webhookURL, title, message, priority string,
) error {
svc.log.Debug(
"sending slack notification",
"url", webhookURL,
"title", title,
)
payload := SlackPayload{
Attachments: []SlackAttachment{
{
Color: svc.slackColor(priority),
Title: title,
Text: message,
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal slack payload: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
webhookURL,
bytes.NewBuffer(body),
)
if err != nil {
return fmt.Errorf("failed to create slack request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send slack request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf("%w: status %d", ErrSlackFailed, resp.StatusCode)
}
return nil
}