mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1519ada0f2 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user