diff --git a/internal/database/migrations/004_add_commit_url.sql b/internal/database/migrations/004_add_commit_url.sql new file mode 100644 index 0000000..4cb33af --- /dev/null +++ b/internal/database/migrations/004_add_commit_url.sql @@ -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; diff --git a/internal/models/deployment.go b/internal/models/deployment.go index a650e33..b830e9e 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -37,6 +37,7 @@ type Deployment struct { AppID string WebhookEventID sql.NullInt64 CommitSHA sql.NullString + CommitURL sql.NullString ImageID sql.NullString ContainerID sql.NullString Status DeploymentStatus @@ -65,7 +66,7 @@ func (d *Deployment) Save(ctx context.Context) error { // Reload refreshes the deployment from the database. func (d *Deployment) Reload(ctx context.Context) error { 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 FROM deployments WHERE id = ?` @@ -156,12 +157,12 @@ func (d *Deployment) FinishedAtFormatted() string { func (d *Deployment) insert(ctx context.Context) error { query := ` 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 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, ) if err != nil { @@ -193,7 +194,7 @@ func (d *Deployment) update(ctx context.Context) error { func (d *Deployment) scan(row *sql.Row) error { 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, ) } @@ -210,7 +211,7 @@ func FindDeployment( deploy.ID = deployID 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 FROM deployments WHERE id = ?`, deployID, @@ -236,7 +237,7 @@ func FindDeploymentsByAppID( limit int, ) ([]*Deployment, error) { 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 FROM deployments WHERE app_id = ? ORDER BY started_at DESC, id DESC LIMIT ?` @@ -255,7 +256,7 @@ func FindDeploymentsByAppID( scanErr := rows.Scan( &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, ) if scanErr != nil { @@ -284,7 +285,7 @@ func LatestDeploymentForApp( deploy := NewDeployment(deployDB) 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 FROM deployments WHERE app_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`, diff --git a/internal/models/webhook_event.go b/internal/models/webhook_event.go index 4265ac5..48f3ef7 100644 --- a/internal/models/webhook_event.go +++ b/internal/models/webhook_event.go @@ -19,6 +19,7 @@ type WebhookEvent struct { EventType string Branch string CommitSHA sql.NullString + CommitURL sql.NullString Payload sql.NullString Matched bool Processed bool @@ -42,8 +43,8 @@ func (w *WebhookEvent) Save(ctx context.Context) error { // Reload refreshes the webhook event from the database. func (w *WebhookEvent) Reload(ctx context.Context) error { query := ` - SELECT id, app_id, event_type, branch, commit_sha, payload, - matched, processed, created_at + SELECT id, app_id, event_type, branch, commit_sha, commit_url, + payload, matched, processed, created_at FROM webhook_events WHERE 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 { query := ` INSERT INTO webhook_events ( - app_id, event_type, branch, commit_sha, payload, matched, processed - ) VALUES (?, ?, ?, ?, ?, ?, ?)` + app_id, event_type, branch, commit_sha, commit_url, payload, matched, processed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` 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, ) if err != nil { @@ -86,7 +87,7 @@ func (w *WebhookEvent) update(ctx context.Context) error { func (w *WebhookEvent) scan(row *sql.Row) error { return row.Scan( &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 row := db.QueryRow(ctx, ` - SELECT id, app_id, event_type, branch, commit_sha, payload, - matched, processed, created_at + SELECT id, app_id, event_type, branch, commit_sha, commit_url, + payload, matched, processed, created_at FROM webhook_events WHERE id = ?`, id, ) @@ -128,8 +129,8 @@ func FindWebhookEventsByAppID( limit int, ) ([]*WebhookEvent, error) { query := ` - SELECT id, app_id, event_type, branch, commit_sha, payload, - matched, processed, created_at + SELECT id, app_id, event_type, branch, commit_sha, commit_url, + payload, matched, processed, created_at FROM webhook_events WHERE app_id = ? ORDER BY created_at DESC LIMIT ?` rows, err := db.Query(ctx, query, appID, limit) @@ -146,7 +147,7 @@ func FindWebhookEventsByAppID( scanErr := rows.Scan( &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, ) if scanErr != nil { @@ -165,8 +166,8 @@ func FindUnprocessedWebhookEvents( db *database.Database, ) ([]*WebhookEvent, error) { query := ` - SELECT id, app_id, event_type, branch, commit_sha, payload, - matched, processed, created_at + SELECT id, app_id, event_type, branch, commit_sha, commit_url, + payload, matched, processed, created_at FROM webhook_events WHERE matched = 1 AND processed = 0 ORDER BY created_at ASC` @@ -184,7 +185,7 @@ func FindUnprocessedWebhookEvents( scanErr := rows.Scan( &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, ) if scanErr != nil { diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 771d1ea..da364a0 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -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 diff --git a/internal/service/notify/notify.go b/internal/service/notify/notify.go index 83e24ff..0cc29da 100644 --- a/internal/service/notify/notify.go +++ b/internal/service/notify/notify.go @@ -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", diff --git a/internal/service/webhook/webhook.go b/internal/service/webhook/webhook.go index 80b8393..9687192 100644 --- a/internal/service/webhook/webhook.go +++ b/internal/service/webhook/webhook.go @@ -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 "" +}