Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
FocusMode: notify.FocusAlways,
})
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely
// log a failed delivery to stderr (never stdout). The sink redacts before
// logging, so a token in the URL or message is masked.
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
})
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
Expand Down
40 changes: 40 additions & 0 deletions internal/notify/webhook_wire.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package notify

import "strings"

// Webhook delivery is configured entirely from the environment. A webhook URL
// typically embeds a secret token, so sourcing it from the environment keeps it
// out of any on-disk config file. The sink is strictly opt-in: with
// EnvWebhookURL unset the wiring helper attaches nothing and the notifier
// behaves exactly as before.
const (
// EnvWebhookURL holds the destination webhook/Slack URL. Empty disables it.
EnvWebhookURL = "ZERO_NOTIFY_WEBHOOK_URL"
// EnvWebhookSummary is an optional one-line run summary attached to every
// payload (for example "nightly audit run").
EnvWebhookSummary = "ZERO_NOTIFY_WEBHOOK_SUMMARY"
)

// MaybeAddWebhookSink attaches a webhook sink to n when a webhook URL is present
// in the environment, and is otherwise a no-op. env resolves an environment
// variable (pass os.Getenv); logf records one redacted line per failed delivery
// (pass nil to stay silent — for example a TUI that owns the screen). It is safe
// to call unconditionally: configuration alone decides whether the sink exists.
//
// The attached sink is still subject to the notifier's Mode/focus policy, so a
// webhook only delivers when notifications are enabled (for example
// `--notify both`), matching the rest of the notification surface.
func MaybeAddWebhookSink(n *Notifier, env func(string) string, logf func(format string, args ...any)) {
if n == nil || env == nil {
return
}
url := strings.TrimSpace(env(EnvWebhookURL))
if url == "" {
return
}
n.AddSink(NewWebhookSink(WebhookConfig{
URL: url,
Summary: strings.TrimSpace(env(EnvWebhookSummary)),
Logf: logf,
}))
}
71 changes: 71 additions & 0 deletions internal/notify/webhook_wire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package notify

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)

// envFunc builds a deterministic env resolver from a map for the wiring tests.
func envFunc(values map[string]string) func(string) string {
return func(key string) string { return values[key] }
}

func TestMaybeAddWebhookSinkAttachesAndDelivers(t *testing.T) {
var hits int32
var gotSummary string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
var payload webhookPayload
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload)
gotSummary = payload.Summary
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
MaybeAddWebhookSink(n, envFunc(map[string]string{
EnvWebhookURL: server.URL,
EnvWebhookSummary: "nightly audit",
}), nil)

if got := len(n.sinks); got != 1 {
t.Fatalf("expected 1 sink attached, got %d", got)
}

n.Notify(Completion, "Zero: ready")
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("webhook hit %d times, want 1", got)
}
if gotSummary != "nightly audit" {
t.Fatalf("summary = %q, want %q", gotSummary, "nightly audit")
}
}

func TestMaybeAddWebhookSinkNoopWhenURLBlank(t *testing.T) {
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})

// Unset.
MaybeAddWebhookSink(n, envFunc(nil), nil)
// Set but blank / whitespace only.
MaybeAddWebhookSink(n, envFunc(map[string]string{EnvWebhookURL: " "}), nil)

if got := len(n.sinks); got != 0 {
t.Fatalf("expected no sink attached, got %d", got)
}
}

func TestMaybeAddWebhookSinkNilGuards(t *testing.T) {
// Must not panic on a nil notifier or nil env resolver.
MaybeAddWebhookSink(nil, envFunc(map[string]string{EnvWebhookURL: "https://example.test"}), nil)

n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
MaybeAddWebhookSink(n, nil, nil)
if got := len(n.sinks); got != 0 {
t.Fatalf("nil env must attach nothing, got %d sinks", got)
}
}
4 changes: 4 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ func newModel(ctx context.Context, options Options) model {
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
})
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Delivery failures stay
// silent here: the TUI owns the alt-screen, so writing to stderr would
// corrupt the display.
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
notifier.SetFocused(true)

m := model{
Expand Down
Loading