From f23014ab12328c845677fa282a43b9a14acff5aa Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 13:09:06 -0400 Subject: [PATCH] feat(alert): add Opsgenie provider Support Opsgenie Alert API v2 with US/EU endpoint selection, configurable priority (P1-P5), and GenieKey auth. TUI form includes API key, priority picker, and EU instance toggle. --- internal/alert/alert.go | 32 +++++++++++++++++ internal/alert/alert_test.go | 70 +++++++++++++++++++++++++++++++++++- internal/tui/tab_alerts.go | 48 +++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 0d83fd0..6b5262e 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -110,6 +110,24 @@ func gotifyPayload(priority string) PayloadFunc { } } +func opsgeniePayload(priority string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]any{ + "message": limitMessage(title, 130), + "description": message, + "source": "uptop", + "priority": priority, + }) + } +} + +func limitMessage(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} + func GetProvider(cfg models.AlertConfig) Provider { switch cfg.Type { case "discord": @@ -173,6 +191,20 @@ func GetProvider(cfg models.AlertConfig) Provider { Payload: gotifyPayload(priority), Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]}, } + case "opsgenie": + priority := "P3" + if p, ok := cfg.Settings["priority"]; ok && p != "" { + priority = p + } + apiURL := "https://api.opsgenie.com/v2/alerts" + if eu, ok := cfg.Settings["eu"]; ok && eu == "true" { + apiURL = "https://api.eu.opsgenie.com/v2/alerts" + } + return &HTTPProvider{ + URL: apiURL, + Payload: opsgeniePayload(priority), + Headers: map[string]string{"Authorization": "GenieKey " + cfg.Settings["api_key"]}, + } default: return nil } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index aca8e32..cf16dde 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -196,8 +196,76 @@ func TestHTTPProviderGotify(t *testing.T) { } } +func TestHTTPProviderOpsgenie(t *testing.T) { + var received map[string]any + var authHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(202) + })) + defer srv.Close() + + p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{ + "api_key": "test-genie-key", + "priority": "P1", + }}) + hp := p.(*HTTPProvider) + hp.URL = srv.URL + + if err := p.Send(context.Background(), "Site Down", "mysite.com is unreachable"); err != nil { + t.Fatalf("Send: %v", err) + } + if authHeader != "GenieKey test-genie-key" { + t.Errorf("expected auth 'GenieKey test-genie-key', got '%s'", authHeader) + } + if received["message"] != "Site Down" { + t.Errorf("unexpected message: %v", received["message"]) + } + if received["description"] != "mysite.com is unreachable" { + t.Errorf("unexpected description: %v", received["description"]) + } + if received["source"] != "uptop" { + t.Errorf("expected source 'uptop', got '%v'", received["source"]) + } + if received["priority"] != "P1" { + t.Errorf("expected priority 'P1', got '%v'", received["priority"]) + } +} + +func TestOpsgenieEUEndpoint(t *testing.T) { + p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{ + "api_key": "key", "eu": "true", + }}) + hp := p.(*HTTPProvider) + if hp.URL != "https://api.eu.opsgenie.com/v2/alerts" { + t.Errorf("expected EU URL, got '%s'", hp.URL) + } +} + +func TestOpsgenieUSEndpoint(t *testing.T) { + p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{ + "api_key": "key", + }}) + hp := p.(*HTTPProvider) + if hp.URL != "https://api.opsgenie.com/v2/alerts" { + t.Errorf("expected US URL, got '%s'", hp.URL) + } +} + +func TestLimitMessage(t *testing.T) { + short := "short" + if got := limitMessage(short, 130); got != short { + t.Errorf("expected '%s', got '%s'", short, got) + } + long := string(make([]byte, 200)) + if got := limitMessage(long, 130); len(got) != 130 { + t.Errorf("expected length 130, got %d", len(got)) + } +} + func TestGetProviderNewTypes(t *testing.T) { - for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} { + for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify", "opsgenie"} { p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{ "token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost", }}) diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index dcb3fb9..7865e40 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -39,6 +39,10 @@ type alertFormData struct { GotifyURL string GotifyToken string GotifyPriority string + // Opsgenie + OpsgenieAPIKey string + OpsgeniePriority string + OpsgenieEU bool } func fmtAlertType(t string) string { @@ -61,6 +65,8 @@ func fmtAlertType(t string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t) case "gotify": return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t) + case "opsgenie": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2684FF")).Render(t) default: return t } @@ -108,6 +114,19 @@ func fmtAlertConfig(alert struct { return limitStr(url, 34) } return subtleStyle.Render("—") + case "opsgenie": + key := alert.Settings["api_key"] + if key != "" { + masked := key + if len(masked) > 8 { + masked = masked[:4] + "…" + masked[len(masked)-4:] + } + if alert.Settings["eu"] == "true" { + return limitStr(fmt.Sprintf("EU %s", masked), 34) + } + return limitStr(masked, 34) + } + return subtleStyle.Render("—") default: if val, ok := alert.Settings["url"]; ok { return limitStr(val, 34) @@ -238,6 +257,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd { NtfyPri: "3", PagerDutySeverity: "critical", GotifyPriority: "5", + OpsgeniePriority: "P3", } if m.editID > 0 { @@ -275,6 +295,10 @@ func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData.GotifyURL = alert.Settings["url"] m.alertFormData.GotifyToken = alert.Settings["token"] m.alertFormData.GotifyPriority = alert.Settings["priority"] + case "opsgenie": + m.alertFormData.OpsgenieAPIKey = alert.Settings["api_key"] + m.alertFormData.OpsgeniePriority = alert.Settings["priority"] + m.alertFormData.OpsgenieEU = alert.Settings["eu"] == "true" } break } @@ -303,6 +327,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd { huh.NewOption("PagerDuty", "pagerduty"), huh.NewOption("Pushover", "pushover"), huh.NewOption("Gotify", "gotify"), + huh.NewOption("Opsgenie", "opsgenie"), ).Value(&m.alertFormData.AlertType), ).Title("Alert Config"), huh.NewGroup( @@ -410,6 +435,23 @@ func (m *Model) initAlertHuhForm() tea.Cmd { ).Title("Gotify Settings").WithHideFunc(func() bool { return m.alertFormData.AlertType != "gotify" }), + huh.NewGroup( + huh.NewInput().Title("API Key"). + Placeholder("your-opsgenie-api-key"). + Value(&m.alertFormData.OpsgenieAPIKey), + huh.NewSelect[string]().Title("Priority"). + Options( + huh.NewOption("Critical (P1)", "P1"), + huh.NewOption("High (P2)", "P2"), + huh.NewOption("Moderate (P3)", "P3"), + huh.NewOption("Low (P4)", "P4"), + huh.NewOption("Informational (P5)", "P5"), + ).Value(&m.alertFormData.OpsgeniePriority), + huh.NewConfirm().Title("EU Instance?"). + Value(&m.alertFormData.OpsgenieEU), + ).Title("Opsgenie Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "opsgenie" + }), ).WithTheme(m.theme.HuhTheme()) return m.huhForm.Init() @@ -446,6 +488,12 @@ func (m *Model) submitAlertForm() { settings["url"] = d.GotifyURL settings["token"] = d.GotifyToken settings["priority"] = d.GotifyPriority + case "opsgenie": + settings["api_key"] = d.OpsgenieAPIKey + settings["priority"] = d.OpsgeniePriority + if d.OpsgenieEU { + settings["eu"] = "true" + } default: settings["url"] = d.WebhookURL }