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
This commit is contained in:
2025-12-31 16:29:22 -08:00
parent 4cd12d717c
commit c4362c3143
6 changed files with 109 additions and 45 deletions

View File

@@ -439,9 +439,15 @@ func (svc *Service) createDeploymentRecord(
}
}
// Set commit SHA from webhook event
if webhookEvent != nil && webhookEvent.CommitSHA.Valid {
deployment.CommitSHA = webhookEvent.CommitSHA
// Set commit SHA and URL from webhook event
if webhookEvent != nil {
if webhookEvent.CommitSHA.Valid {
deployment.CommitSHA = webhookEvent.CommitSHA
}
if webhookEvent.CommitURL.Valid {
deployment.CommitURL = webhookEvent.CommitURL
}
}
deployment.Status = models.DeploymentStatusBuilding

View File

@@ -4,6 +4,7 @@ package notify
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -73,14 +74,24 @@ func (svc *Service) NotifyBuildStart(
deployment *models.Deployment,
) {
title := "Build started: " + app.Name
message := "Building from branch " + app.Branch
// Plain text message for ntfy
ntfyMessage := "Building from branch " + app.Branch
if deployment.CommitSHA.Valid {
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
message += " at " + shortSHA
shortSHA := truncateSHA(deployment.CommitSHA.String)
ntfyMessage += " at " + shortSHA
}
svc.sendNotifications(ctx, app, title, message, "info")
// 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.
@@ -93,7 +104,7 @@ func (svc *Service) NotifyBuildSuccess(
title := "Build success: " + app.Name
message := "Image built successfully in " + formatDuration(duration)
svc.sendNotifications(ctx, app, title, message, "success")
svc.sendNotifications(ctx, app, title, message, message, "success")
}
// NotifyBuildFailed sends a build failed notification.
@@ -107,7 +118,7 @@ func (svc *Service) NotifyBuildFailed(
title := "Build failed: " + app.Name
message := "Build failed after " + formatDuration(duration) + ": " + buildErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
svc.sendNotifications(ctx, app, title, message, message, "error")
}
// NotifyDeploySuccess sends a deploy success notification.
@@ -118,14 +129,24 @@ func (svc *Service) NotifyDeploySuccess(
) {
duration := time.Since(deployment.StartedAt)
title := "Deploy success: " + app.Name
message := "Successfully deployed in " + formatDuration(duration)
// Plain text message for ntfy
ntfyMessage := "Successfully deployed in " + formatDuration(duration)
if deployment.CommitSHA.Valid {
shortSHA := deployment.CommitSHA.String[:minInt(shortCommitLength, len(deployment.CommitSHA.String))]
message += " (commit " + shortSHA + ")"
shortSHA := truncateSHA(deployment.CommitSHA.String)
ntfyMessage += " (commit " + shortSHA + ")"
}
svc.sendNotifications(ctx, app, title, message, "success")
// 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.
@@ -139,7 +160,7 @@ func (svc *Service) NotifyDeployFailed(
title := "Deploy failed: " + app.Name
message := "Deployment failed after " + formatDuration(duration) + ": " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
svc.sendNotifications(ctx, app, title, message, message, "error")
}
// formatDuration formats a duration for display.
@@ -154,19 +175,28 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
// minInt returns the smaller of two integers.
func minInt(a, b int) int {
if a < b {
return a
// truncateSHA truncates a commit SHA to shortCommitLength characters.
func truncateSHA(sha string) string {
if len(sha) > shortCommitLength {
return sha[:shortCommitLength]
}
return b
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, message, priority string,
title, ntfyMessage, slackMessage, priority string,
) {
// Send to ntfy if configured
if app.NtfyTopic.Valid && app.NtfyTopic.String != "" {
@@ -178,7 +208,7 @@ func (svc *Service) sendNotifications(
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, message, priority)
ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, ntfyMessage, priority)
if ntfyErr != nil {
svc.log.Error(
"failed to send ntfy notification",
@@ -199,7 +229,7 @@ func (svc *Service) sendNotifications(
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message, priority)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, slackMessage, priority)
if slackErr != nil {
svc.log.Error(
"failed to send slack notification",

View File

@@ -50,10 +50,12 @@ type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
@@ -61,6 +63,7 @@ type GiteaPushPayload struct {
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL string `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
@@ -90,6 +93,7 @@ func (svc *Service) HandleWebhook(
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches
matched := branch == app.Branch
@@ -100,6 +104,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -160,3 +165,21 @@ func extractBranch(ref string) string {
return ref
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) string {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return payload.Repository.HTMLURL + "/commit/" + payload.After
}
return ""
}