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

View File

@ -0,0 +1,3 @@
-- Add commit_url column to webhook_events and deployments tables
ALTER TABLE webhook_events ADD COLUMN commit_url TEXT;
ALTER TABLE deployments ADD COLUMN commit_url TEXT;

View File

@ -37,6 +37,7 @@ type Deployment struct {
AppID string AppID string
WebhookEventID sql.NullInt64 WebhookEventID sql.NullInt64
CommitSHA sql.NullString CommitSHA sql.NullString
CommitURL sql.NullString
ImageID sql.NullString ImageID sql.NullString
ContainerID sql.NullString ContainerID sql.NullString
Status DeploymentStatus Status DeploymentStatus
@ -65,7 +66,7 @@ func (d *Deployment) Save(ctx context.Context) error {
// Reload refreshes the deployment from the database. // Reload refreshes the deployment from the database.
func (d *Deployment) Reload(ctx context.Context) error { func (d *Deployment) Reload(ctx context.Context) error {
query := ` query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id, SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id,
container_id, status, logs, started_at, finished_at container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?` FROM deployments WHERE id = ?`
@ -156,12 +157,12 @@ func (d *Deployment) FinishedAtFormatted() string {
func (d *Deployment) insert(ctx context.Context) error { func (d *Deployment) insert(ctx context.Context) error {
query := ` query := `
INSERT INTO deployments ( INSERT INTO deployments (
app_id, webhook_event_id, commit_sha, image_id, app_id, webhook_event_id, commit_sha, commit_url, image_id,
container_id, status, logs container_id, status, logs
) VALUES (?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
result, err := d.db.Exec(ctx, query, result, err := d.db.Exec(ctx, query,
d.AppID, d.WebhookEventID, d.CommitSHA, d.ImageID, d.AppID, d.WebhookEventID, d.CommitSHA, d.CommitURL, d.ImageID,
d.ContainerID, d.Status, d.Logs, d.ContainerID, d.Status, d.Logs,
) )
if err != nil { if err != nil {
@ -193,7 +194,7 @@ func (d *Deployment) update(ctx context.Context) error {
func (d *Deployment) scan(row *sql.Row) error { func (d *Deployment) scan(row *sql.Row) error {
return row.Scan( return row.Scan(
&d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.ImageID, &d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.CommitURL, &d.ImageID,
&d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt, &d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt,
) )
} }
@ -210,7 +211,7 @@ func FindDeployment(
deploy.ID = deployID deploy.ID = deployID
row := deployDB.QueryRow(ctx, ` row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id, SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id,
container_id, status, logs, started_at, finished_at container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`, FROM deployments WHERE id = ?`,
deployID, deployID,
@ -236,7 +237,7 @@ func FindDeploymentsByAppID(
limit int, limit int,
) ([]*Deployment, error) { ) ([]*Deployment, error) {
query := ` query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id, SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id,
container_id, status, logs, started_at, finished_at container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ? FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT ?` ORDER BY started_at DESC, id DESC LIMIT ?`
@ -255,7 +256,7 @@ func FindDeploymentsByAppID(
scanErr := rows.Scan( scanErr := rows.Scan(
&deploy.ID, &deploy.AppID, &deploy.WebhookEventID, &deploy.ID, &deploy.AppID, &deploy.WebhookEventID,
&deploy.CommitSHA, &deploy.ImageID, &deploy.ContainerID, &deploy.CommitSHA, &deploy.CommitURL, &deploy.ImageID, &deploy.ContainerID,
&deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt, &deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt,
) )
if scanErr != nil { if scanErr != nil {
@ -284,7 +285,7 @@ func LatestDeploymentForApp(
deploy := NewDeployment(deployDB) deploy := NewDeployment(deployDB)
row := deployDB.QueryRow(ctx, ` row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id, SELECT id, app_id, webhook_event_id, commit_sha, commit_url, image_id,
container_id, status, logs, started_at, finished_at container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ? FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT 1`, ORDER BY started_at DESC, id DESC LIMIT 1`,

View File

@ -19,6 +19,7 @@ type WebhookEvent struct {
EventType string EventType string
Branch string Branch string
CommitSHA sql.NullString CommitSHA sql.NullString
CommitURL sql.NullString
Payload sql.NullString Payload sql.NullString
Matched bool Matched bool
Processed bool Processed bool
@ -42,8 +43,8 @@ func (w *WebhookEvent) Save(ctx context.Context) error {
// Reload refreshes the webhook event from the database. // Reload refreshes the webhook event from the database.
func (w *WebhookEvent) Reload(ctx context.Context) error { func (w *WebhookEvent) Reload(ctx context.Context) error {
query := ` query := `
SELECT id, app_id, event_type, branch, commit_sha, payload, SELECT id, app_id, event_type, branch, commit_sha, commit_url,
matched, processed, created_at payload, matched, processed, created_at
FROM webhook_events WHERE id = ?` FROM webhook_events WHERE id = ?`
row := w.db.QueryRow(ctx, query, w.ID) row := w.db.QueryRow(ctx, query, w.ID)
@ -54,11 +55,11 @@ func (w *WebhookEvent) Reload(ctx context.Context) error {
func (w *WebhookEvent) insert(ctx context.Context) error { func (w *WebhookEvent) insert(ctx context.Context) error {
query := ` query := `
INSERT INTO webhook_events ( INSERT INTO webhook_events (
app_id, event_type, branch, commit_sha, payload, matched, processed app_id, event_type, branch, commit_sha, commit_url, payload, matched, processed
) VALUES (?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
result, err := w.db.Exec(ctx, query, result, err := w.db.Exec(ctx, query,
w.AppID, w.EventType, w.Branch, w.CommitSHA, w.AppID, w.EventType, w.Branch, w.CommitSHA, w.CommitURL,
w.Payload, w.Matched, w.Processed, w.Payload, w.Matched, w.Processed,
) )
if err != nil { if err != nil {
@ -86,7 +87,7 @@ func (w *WebhookEvent) update(ctx context.Context) error {
func (w *WebhookEvent) scan(row *sql.Row) error { func (w *WebhookEvent) scan(row *sql.Row) error {
return row.Scan( return row.Scan(
&w.ID, &w.AppID, &w.EventType, &w.Branch, &w.CommitSHA, &w.ID, &w.AppID, &w.EventType, &w.Branch, &w.CommitSHA,
&w.Payload, &w.Matched, &w.Processed, &w.CreatedAt, &w.CommitURL, &w.Payload, &w.Matched, &w.Processed, &w.CreatedAt,
) )
} }
@ -102,8 +103,8 @@ func FindWebhookEvent(
event.ID = id event.ID = id
row := db.QueryRow(ctx, ` row := db.QueryRow(ctx, `
SELECT id, app_id, event_type, branch, commit_sha, payload, SELECT id, app_id, event_type, branch, commit_sha, commit_url,
matched, processed, created_at payload, matched, processed, created_at
FROM webhook_events WHERE id = ?`, FROM webhook_events WHERE id = ?`,
id, id,
) )
@ -128,8 +129,8 @@ func FindWebhookEventsByAppID(
limit int, limit int,
) ([]*WebhookEvent, error) { ) ([]*WebhookEvent, error) {
query := ` query := `
SELECT id, app_id, event_type, branch, commit_sha, payload, SELECT id, app_id, event_type, branch, commit_sha, commit_url,
matched, processed, created_at payload, matched, processed, created_at
FROM webhook_events WHERE app_id = ? ORDER BY created_at DESC LIMIT ?` FROM webhook_events WHERE app_id = ? ORDER BY created_at DESC LIMIT ?`
rows, err := db.Query(ctx, query, appID, limit) rows, err := db.Query(ctx, query, appID, limit)
@ -146,7 +147,7 @@ func FindWebhookEventsByAppID(
scanErr := rows.Scan( scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch, &event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched, &event.CommitSHA, &event.CommitURL, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt, &event.Processed, &event.CreatedAt,
) )
if scanErr != nil { if scanErr != nil {
@ -165,8 +166,8 @@ func FindUnprocessedWebhookEvents(
db *database.Database, db *database.Database,
) ([]*WebhookEvent, error) { ) ([]*WebhookEvent, error) {
query := ` query := `
SELECT id, app_id, event_type, branch, commit_sha, payload, SELECT id, app_id, event_type, branch, commit_sha, commit_url,
matched, processed, created_at payload, matched, processed, created_at
FROM webhook_events FROM webhook_events
WHERE matched = 1 AND processed = 0 ORDER BY created_at ASC` WHERE matched = 1 AND processed = 0 ORDER BY created_at ASC`
@ -184,7 +185,7 @@ func FindUnprocessedWebhookEvents(
scanErr := rows.Scan( scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch, &event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched, &event.CommitSHA, &event.CommitURL, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt, &event.Processed, &event.CreatedAt,
) )
if scanErr != nil { if scanErr != nil {

View File

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

View File

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

View File

@ -50,10 +50,12 @@ type GiteaPushPayload struct {
Ref string `json:"ref"` Ref string `json:"ref"`
Before string `json:"before"` Before string `json:"before"`
After string `json:"after"` After string `json:"after"`
CompareURL string `json:"compare_url"`
Repository struct { Repository struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"` SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
} `json:"repository"` } `json:"repository"`
Pusher struct { Pusher struct {
Username string `json:"username"` Username string `json:"username"`
@ -61,6 +63,7 @@ type GiteaPushPayload struct {
} `json:"pusher"` } `json:"pusher"`
Commits []struct { Commits []struct {
ID string `json:"id"` ID string `json:"id"`
URL string `json:"url"`
Message string `json:"message"` Message string `json:"message"`
Author struct { Author struct {
Name string `json:"name"` Name string `json:"name"`
@ -90,6 +93,7 @@ func (svc *Service) HandleWebhook(
// Extract branch from ref // Extract branch from ref
branch := extractBranch(pushPayload.Ref) branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches // Check if branch matches
matched := branch == app.Branch matched := branch == app.Branch
@ -100,6 +104,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType event.EventType = eventType
event.Branch = branch event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} 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.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched event.Matched = matched
event.Processed = false event.Processed = false
@ -160,3 +165,21 @@ func extractBranch(ref string) string {
return ref 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 ""
}