feat(web): add opt-in webhook alert actions

An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.

GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.

The ghtracker_action_requests_total counter records each result.
This commit is contained in:
2026-10-04 16:35:26 +01:00
parent 32de5d6704
commit 5824c4dd10
9 changed files with 980 additions and 14 deletions
+81
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"
@@ -28,13 +29,49 @@ const (
MinRefresh = time.Minute
dataStaleGrace = 30 * time.Minute
defaultDataStaleFactor = 2
DefaultActionTimeout = 10 * time.Second
)
var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// Action is an operator-defined button on alerts. Pressing it POSTs the alert to a webhook.
type Action struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Confirm string `yaml:"confirm"`
StatusURL string `yaml:"status_url"`
// Token comes from the environment variable named by Webhook.TokenEnv, never from the file.
Token string `yaml:"-"`
Webhook Webhook `yaml:"webhook"`
// Kinds limits the action to these alert kinds; empty means every kind.
Kinds []string `yaml:"kinds"`
}
type Webhook struct {
URL string `yaml:"url"`
TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"`
}
// AppliesTo reports whether the action is offered for an alert kind.
func (a Action) AppliesTo(kind string) bool {
if len(a.Kinds) == 0 {
return true
}
for _, k := range a.Kinds {
if k == kind {
return true
}
}
return false
}
type Config struct {
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
ManualRefresh *bool `yaml:"manual_refresh"`
Listen string `yaml:"listen"`
Repos []string `yaml:"repos"`
Actions []Action `yaml:"actions"`
GitHub GitHub `yaml:"github"`
Thresholds Thresholds `yaml:"thresholds"`
RefreshInterval time.Duration `yaml:"refresh_interval"`
@@ -127,6 +164,10 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
c.GitHub.PrivateKeyPEM = string(pem)
}
if err := finalizeActions(c.Actions, getenv); err != nil {
return err
}
repos, err := normalizeRepos(c.Repos)
if err != nil {
return err
@@ -135,6 +176,46 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
return nil
}
func finalizeActions(actions []Action, getenv func(string) string) error {
seen := make(map[string]bool, len(actions))
for i := range actions {
a := &actions[i]
if !actionIDPattern.MatchString(a.ID) {
return fmt.Errorf("actions[%d].id %q must match [a-z0-9-]+", i, a.ID)
}
if seen[a.ID] {
return fmt.Errorf("actions[%d].id %q is used twice", i, a.ID)
}
seen[a.ID] = true
if a.Label == "" {
a.Label = a.ID
}
if !isHTTPURL(a.Webhook.URL) {
return fmt.Errorf("actions[%s].webhook.url %q must be an http or https address", a.ID, a.Webhook.URL)
}
if a.StatusURL != "" && !isHTTPURL(a.StatusURL) {
return fmt.Errorf("actions[%s].status_url %q must be an http or https address", a.ID, a.StatusURL)
}
if a.Webhook.Timeout < 0 {
return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID)
}
if a.Webhook.Timeout == 0 {
a.Webhook.Timeout = DefaultActionTimeout
}
if env := a.Webhook.TokenEnv; env != "" {
if a.Token = getenv(env); a.Token == "" {
return fmt.Errorf("actions[%s].webhook.token_env: $%s is empty", a.ID, env)
}
}
}
return nil
}
func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 {
*d = def
+76
View File
@@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -145,3 +146,78 @@ func TestWebBase(t *testing.T) {
}
}
}
func TestActionsDefaultOff(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if len(c.Actions) != 0 {
t.Fatalf("actions must default to none: %+v", c.Actions)
}
}
func TestActionsFinalize(t *testing.T) {
good := func() Action {
return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}}
}
tests := map[string]struct {
mutate func(*Action)
extra []Action
wantErr bool
}{
"ok": {mutate: func(*Action) {}},
"no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }},
"bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true},
"empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true},
"duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true},
"bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true},
"no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true},
"bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true},
"status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }},
"negative timeout": {mutate: func(a *Action) { a.Webhook.Timeout = -time.Second }, wantErr: true},
"token env unset": {mutate: func(a *Action) { a.Webhook.TokenEnv = "MISSING" }, wantErr: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
a := good()
tc.mutate(&a)
c := valid()
c.Actions = append([]Action{a}, tc.extra...)
err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if err != nil && strings.Contains(err.Error(), "s3cret") {
t.Fatal("error leaks token")
}
if err == nil {
got := c.Actions[0]
if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout {
t.Fatalf("defaults wrong: %+v", got)
}
if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" {
t.Fatalf("token not read: %+v", got)
}
}
})
}
}
func TestActionAppliesTo(t *testing.T) {
tests := []struct {
kind string
kinds []string
want bool
}{
{kinds: nil, kind: "anything", want: true},
{kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true},
{kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false},
{kinds: []string{"a", "b"}, kind: "b", want: true},
}
for _, tc := range tests {
if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want {
t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got)
}
}
}