diff --git a/README.md b/README.md index 0fa170b..660c329 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,29 @@ actions: - A `link` that is not an http or https address is dropped. - The dashboard shows one button per action on matching alerts, a confirm dialog when `confirm` is set, and a status chip once a status is known. The chip links to `link` when there is one. The page reads the status on its normal 60-second poll and once right after a press. +### Automatic actions + +An action with `auto: true` fires without a click. After every scan, and once at startup, the server sends the webhook for each current alert whose kind is in `kinds`. It sends once per action, repo, alert URL and head commit. A new commit on a pull request is a new send. + +```yaml +actions: + - id: review + label: Review + kinds: [pr_open] # required when auto is true + auto: true + authors: [lukaszraczylo, dependabot[bot]] # optional; case-insensitive; empty means any author + webhook: + url: https://example.internal/hooks/review + token_env: ACTION_TOKEN +``` + +- `pr_open` is an informational alert kind. It exists for each open, non-draft pull request from the same repository (not a fork) in a non-archived repo. `subject` is `#N title`, `url` is the pull request, `ref` is the head commit SHA and `detail` is `open for `. It has severity `ok`: it never changes repo health, the counts, the metrics or the dashboard, and the UI hides it. It is in the `alerts` array of `GET /api/state`, which gains an optional `ref` field on every alert. +- `authors` limits auto sends to alerts that carry an author, which today means `pr_open`. Without `authors`, any alert of a listed kind fires. +- The webhook request is the same as for a button press, plus `alert.ref` when the alert has one. +- The server keeps the sent set in memory and drops an entry when its alert disappears, so a restart sends again for every open pull request. A failed send is not recorded and retries on the next scan. That covers a network error, a non-2xx answer and a `409` (the consumer is busy). Any other 2xx answer counts as sent. +- A scan makes at most 5 auto sends, in order, so a first start cannot flood the webhook. The rest follow on later scans. +- `action_requests_total` counts auto sends with the results `auto_ok`, `auto_rejected` (4xx), `auto_error` (network error or 5xx) and `auto_busy` (409). + ## Metrics All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. @@ -108,7 +131,7 @@ All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. | `release_info`, `release_published_timestamp_seconds` | repo, version, url | | `alerts` | severity | | `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none | -| `action_requests_total` (counter; result is `ok`, `rejected` or `error`; only with `actions`) | action, result | +| `action_requests_total` (counter; result is `ok`, `rejected` or `error`, plus `auto_*` for auto actions; only with `actions`) | action, result | Example alert rules: diff --git a/config.example.yaml b/config.example.yaml index 665fddf..df672ae 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,6 +25,13 @@ thresholds: # negative disables a check # timeout: 10s # status_url: https://example.internal/hooks/rerun/status # optional # confirm: "Run this action for the selected alert?" # optional; omit for no dialog +# - id: review +# kinds: [pr_open] # required with auto; one alert per open, non-draft, non-fork PR +# auto: true # fire without a click, once per repo, PR and head commit +# authors: [lukaszraczylo] # optional; case-insensitive; omit for any author +# webhook: +# url: https://example.internal/hooks/review +# token_env: ACTION_TOKEN max_items: 100 # per repo, issues and PRs concurrency: 2 diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 059f50f..6ae067f 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -65,6 +65,7 @@ type Collector struct { log *slog.Logger webBase string kick chan struct{} + onRefresh []func(context.Context, View) names []string th config.Thresholds interval time.Duration @@ -100,19 +101,33 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector { func (c *Collector) Run(ctx context.Context) { t := time.NewTicker(c.interval) defer t.Stop() - c.Refresh(ctx) + c.refreshAndNotify(ctx) for { select { case <-ctx.Done(): return case <-t.C: - c.Refresh(ctx) + c.refreshAndNotify(ctx) case <-c.kick: - c.Refresh(ctx) + c.refreshAndNotify(ctx) } } } +// OnRefresh registers fn to run after every scan started by Run; call it before Run. +func (c *Collector) OnRefresh(fn func(context.Context, View)) { c.onRefresh = append(c.onRefresh, fn) } + +func (c *Collector) refreshAndNotify(ctx context.Context) { + c.Refresh(ctx) + if len(c.onRefresh) == 0 || ctx.Err() != nil { + return + } + v := c.View() + for _, fn := range c.onRefresh { + fn(ctx, v) + } +} + // RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued. func (c *Collector) RequestRefresh() bool { if c.refreshing.Load() { diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index af7010b..3275b4b 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -66,19 +66,19 @@ func TestEvaluate(t *testing.T) { r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}} }, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn}, "pr fresh": {func(r *model.Repo) { - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-47 * time.Hour)}} }, map[string]model.Severity{}, model.OK}, "pr waiting warn": {func(r *model.Repo) { - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-49 * time.Hour)}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-49 * time.Hour)}} }, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn}, "pr waiting crit": {func(r *model.Repo) { - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-8 * 24 * time.Hour)}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-8 * 24 * time.Hour)}} }, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit}, "draft pr ignored": {func(r *model.Repo) { r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}} }, map[string]model.Severity{}, model.OK}, "pr checks failing": {func(r *model.Repo) { - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}} }, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn}, "issues stale aggregated": {func(r *model.Repo) { old := t0.Add(-40 * 24 * time.Hour) @@ -86,7 +86,7 @@ func TestEvaluate(t *testing.T) { }, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn}, "archived repo skips work alerts": {func(r *model.Repo) { r.Archived = true - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-30 * 24 * time.Hour)}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-30 * 24 * time.Hour)}} }, map[string]model.Severity{}, model.OK}, } for name, tt := range tests { @@ -111,7 +111,7 @@ func TestEvaluate(t *testing.T) { } func TestEvaluateDisabledThresholds(t *testing.T) { - r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-100 * 24 * time.Hour)}}, + r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-100 * 24 * time.Hour)}}, Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}} off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1} if a := Evaluate(&r, t0, off); len(a) != 0 { @@ -120,7 +120,7 @@ func TestEvaluateDisabledThresholds(t *testing.T) { } func TestAgeAdvancesWithoutRefresh(t *testing.T) { - r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}} + r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-40 * time.Hour)}}} cfg := th() cfg.DataStaleAfter = -1 if a := Evaluate(&r, t0, cfg); len(a) != 0 { @@ -230,7 +230,7 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) { c := &model.Run{Status: "completed", Conclusion: "failure"} r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}} case "o/warn": - r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-72 * time.Hour)}} + r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-72 * time.Hour)}} } return r, nil }} @@ -457,3 +457,53 @@ func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) { t.Fatalf("%+v", c.View().Repos) } } + +func TestEvaluatePROpen(t *testing.T) { + pr := func(mut func(*model.PullRequest)) model.PullRequest { + p := model.PullRequest{Number: 7, Title: "fix", URL: "https://github.com/o/r/pull/7", Author: "alice", HeadSHA: "abc123", CreatedAt: t0.Add(-5 * time.Hour)} + mut(&p) + return p + } + tests := map[string]struct { + pr model.PullRequest + archived bool + want bool + }{ + "open": {pr(func(*model.PullRequest) {}), false, true}, + "fork": {pr(func(p *model.PullRequest) { p.Fork = true }), false, false}, + "draft": {pr(func(p *model.PullRequest) { p.Draft = true }), false, false}, + "archived": {pr(func(*model.PullRequest) {}), true, false}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: t0, Archived: tc.archived, PRs: []model.PullRequest{tc.pr}} + alerts := Evaluate(&r, t0, th()) + if r.Health != model.OK { + t.Fatalf("health = %v, want ok", r.Health) + } + if !tc.want { + if len(alerts) != 0 { + t.Fatalf("alerts = %+v, want none", alerts) + } + return + } + want := model.Alert{Severity: model.OK, Repo: "o/r", Kind: KindPROpen, Subject: "#7 fix", Detail: "open for 5h", + URL: tc.pr.URL, Ref: "abc123", Author: "alice", Since: tc.pr.CreatedAt} + if len(alerts) != 1 || alerts[0] != want { + t.Fatalf("alerts = %+v, want [%+v]", alerts, want) + } + }) + } +} + +func TestPROpenDoesNotChangeCounts(t *testing.T) { + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + return model.Repo{FullName: name, PRs: []model.PullRequest{{Number: 1, HeadSHA: "s", CreatedAt: t0}}}, nil + }} + c := newCollector(f, "o/a") + c.Refresh(context.Background()) + v := c.View() + if len(v.Alerts) != 1 || v.Alerts[0].Kind != KindPROpen || v.Crit != 0 || v.Warn != 0 || v.Repos[0].Health != model.OK { + t.Fatalf("view = %+v", v) + } +} diff --git a/internal/collector/health.go b/internal/collector/health.go index 6218e3b..2f5b75c 100644 --- a/internal/collector/health.go +++ b/internal/collector/health.go @@ -20,6 +20,8 @@ const ( KindPRWaiting = "pr_waiting" KindPRChecks = "pr_checks_failing" KindIssuesStale = "issues_stale" + // KindPROpen is informational plumbing for automatic actions; it has OK severity so it never moves health or counts. + KindPROpen = "pr_open" ) // Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health. @@ -62,6 +64,10 @@ func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert } age := now.Sub(pr.CreatedAt) subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title) + if !pr.Fork { + alerts = append(alerts, model.Alert{Severity: model.OK, Repo: r.FullName, Kind: KindPROpen, Subject: subject, + Detail: "open for " + Age(age), URL: pr.URL, Ref: pr.HeadSHA, Author: pr.Author, Since: pr.CreatedAt}) + } switch { case th.PRCritAfter > 0 && age >= th.PRCritAfter: add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt) diff --git a/internal/config/config.go b/internal/config/config.go index 7e88a7d..9ceee97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,23 @@ type Action struct { Webhook Webhook `yaml:"webhook"` // Kinds limits the action to these alert kinds; empty means every kind. Kinds []string `yaml:"kinds"` + // Authors limits auto firing to pull requests by these logins, case-insensitive; empty means anyone. + Authors []string `yaml:"authors"` + // Auto fires the webhook without a click, once per alert and head commit; it requires Kinds. + Auto bool `yaml:"auto"` +} + +// AuthorAllowed reports whether auto firing accepts a pull request author. +func (a Action) AuthorAllowed(author string) bool { + if len(a.Authors) == 0 { + return true + } + for _, x := range a.Authors { + if strings.EqualFold(x, author) { + return true + } + } + return false } type Webhook struct { @@ -196,6 +213,9 @@ func finalizeActions(actions []Action, getenv func(string) string) error { 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.Auto && len(a.Kinds) == 0 { + return fmt.Errorf("actions[%s].auto requires a non-empty kinds list", a.ID) + } if a.Webhook.Timeout < 0 { return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aa6c87f..b5594cc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -177,6 +177,8 @@ func TestActionsFinalize(t *testing.T) { "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}, + "auto with kinds": {mutate: func(a *Action) { a.Auto, a.Kinds = true, []string{"pr_open"} }}, + "auto no kinds": {mutate: func(a *Action) { a.Auto = true }, wantErr: true}, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -221,3 +223,24 @@ func TestActionAppliesTo(t *testing.T) { } } } + +func TestActionAuthorAllowed(t *testing.T) { + tests := map[string]struct { + author string + authors []string + want bool + }{ + "no filter": {"anyone", nil, true}, + "listed": {"alice", []string{"alice"}, true}, + "case-insensitive": {"aLICE", []string{"Alice"}, true}, + "unlisted": {"bob", []string{"alice"}, false}, + "empty author": {"", []string{"alice"}, false}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if got := (Action{Authors: tc.authors}).AuthorAllowed(tc.author); got != tc.want { + t.Fatalf("AuthorAllowed(%q) = %v, want %v", tc.author, got, tc.want) + } + }) + } +} diff --git a/internal/gh/fetch.go b/internal/gh/fetch.go index c3e1b93..a46465e 100644 --- a/internal/gh/fetch.go +++ b/internal/gh/fetch.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "slices" + "strings" "time" "github.com/lukaszraczylo/ghtracker/internal/model" @@ -129,8 +130,12 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, HTMLURL string `json:"html_url"` User userRef `json:"user"` Head struct { - SHA string `json:"sha"` + Repo *repoRef `json:"repo"` + SHA string `json:"sha"` } `json:"head"` + Base struct { + Repo *repoRef `json:"repo"` + } `json:"base"` Labels []labelRef `json:"labels"` Number int `json:"number"` Draft bool `json:"draft"` @@ -141,13 +146,22 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, for _, p := range items { out = append(out, model.PullRequest{ Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft, - Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, Fork: isFork(p.Head.Repo, p.Base.Repo), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, Checks: model.CheckSummary{State: model.ChecksUnknown}, }) } return out, err } +type repoRef struct { + FullName string `json:"full_name"` +} + +// isFork reports a head repo that differs from the base repo; a deleted head repo counts as a fork. +func isFork(head, base *repoRef) bool { + return head == nil || base == nil || !strings.EqualFold(head.FullName, base.FullName) +} + // attachChecks fills each PR's check summary; it reports the first failure but still tries every PR. func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error { var first error diff --git a/internal/gh/gh_test.go b/internal/gh/gh_test.go index 4a44037..3e2cf69 100644 --- a/internal/gh/gh_test.go +++ b/internal/gh/gh_test.go @@ -553,3 +553,29 @@ func TestFreshClientDisablesConnectionReuse(t *testing.T) { type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestOpenPRsHeadSHAAndFork(t *testing.T) { + tests := map[string]struct { + pr string + wantFork bool + }{ + "same repo": {`"head":{"sha":"abc","repo":{"full_name":"o/r"}},"base":{"repo":{"full_name":"o/r"}}`, false}, + "same repo casing": {`"head":{"sha":"abc","repo":{"full_name":"O/R"}},"base":{"repo":{"full_name":"o/r"}}`, false}, + "other repo": {`"head":{"sha":"abc","repo":{"full_name":"x/r"}},"base":{"repo":{"full_name":"o/r"}}`, true}, + "deleted head": {`"head":{"sha":"abc","repo":null},"base":{"repo":{"full_name":"o/r"}}`, true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + f := newFake(t) + c := f.client(t, 50) + f.json("/repos/o/r/pulls", `[{"number":2,"title":"t","html_url":"u2","user":{"login":"b"},`+tc.pr+`}]`) + prs, err := c.openPRs(context.Background(), "o/r") + if err != nil || len(prs) != 1 { + t.Fatalf("prs = %+v, err = %v", prs, err) + } + if prs[0].HeadSHA != "abc" || prs[0].Fork != tc.wantFork { + t.Fatalf("HeadSHA = %q, Fork = %v, want abc, %v", prs[0].HeadSHA, prs[0].Fork, tc.wantFork) + } + }) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index f393c76..53c1ff2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -90,6 +90,8 @@ type PullRequest struct { Checks CheckSummary Number int Draft bool + // Fork is true when the head repository differs from the base or was deleted. + Fork bool } type CheckSummary struct { @@ -133,12 +135,16 @@ func (r Run) Failed() bool { } type Alert struct { - Since time.Time - Repo string - Kind string - Subject string - Detail string - URL string + Since time.Time + Repo string + Kind string + Subject string + Detail string + URL string + // Ref is the head commit of a pr_open alert; empty for other kinds. + Ref string + // Author is the pull request author of a pr_open alert; it is not part of the JSON API. + Author string Severity Severity } diff --git a/internal/web/actions.go b/internal/web/actions.go index 579447b..7a41e3b 100644 --- a/internal/web/actions.go +++ b/internal/web/actions.go @@ -29,6 +29,10 @@ const ( resultOK = "ok" resultRejected = "rejected" resultFailed = "error" + resultAutoOK = "auto_ok" + resultAutoBusy = "auto_busy" + resultAutoRefused = "auto_rejected" + resultAutoFailed = "auto_error" actionMetricName = "action_requests_total" contentTypeHeader = "Content-Type" ) @@ -93,6 +97,11 @@ func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registere for _, r := range []string{resultOK, resultRejected, resultFailed} { a.counter.WithLabelValues(c.ID, r) } + if c.Auto { + for _, r := range []string{resultAutoOK, resultAutoBusy, resultAutoRefused, resultAutoFailed} { + a.counter.WithLabelValues(c.ID, r) + } + } } if reg != nil { reg.MustRegister(a.counter) @@ -221,6 +230,7 @@ type webhookAlert struct { Subject string `json:"subject"` Detail string `json:"detail"` URL string `json:"url"` + Ref string `json:"ref,omitempty"` } type webhookPayload struct { @@ -250,11 +260,7 @@ func (s *Server) runAction(w http.ResponseWriter, r *http.Request) { writeJSONError(w, code, msg) return } - status, body, err := s.actions.post(r.Context(), rt.cfg, webhookPayload{ - Action: rt.cfg.ID, RequestedAt: s.now().UTC(), - Alert: webhookAlert{Repo: alert.Repo, Kind: alert.Kind, Severity: alert.Severity.String(), Subject: alert.Subject, - Detail: alert.Detail, URL: alert.URL, Since: alert.Since}, - }) + status, body, err := s.actions.post(r.Context(), rt.cfg, newWebhookPayload(rt.cfg.ID, alert, s.now())) if err != nil { s.actions.count(rt.cfg.ID, resultFailed) s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err) @@ -286,6 +292,14 @@ func (s *Server) runAction(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(cleanStatus(st)) } +func newWebhookPayload(id string, a model.Alert, at time.Time) webhookPayload { + return webhookPayload{ + Action: id, RequestedAt: at.UTC(), + Alert: webhookAlert{Repo: a.Repo, Kind: a.Kind, Severity: a.Severity.String(), Subject: a.Subject, + Detail: a.Detail, URL: a.URL, Ref: a.Ref, Since: a.Since}, + } +} + // findAlert returns the current alert a request targets, or a status and message when it has none. func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) { v := s.src.View() diff --git a/internal/web/api.go b/internal/web/api.go index 1664c75..d11549e 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -95,6 +95,7 @@ type apiAlert struct { Subject string `json:"subject"` Detail string `json:"detail"` URL string `json:"url"` + Ref string `json:"ref,omitempty"` } func seconds(d time.Duration) int64 { return int64(d / time.Second) } @@ -133,7 +134,7 @@ func buildState(v collector.View, manualRefresh bool) apiState { for _, a := range v.Alerts { st.Alerts = append(st.Alerts, apiAlert{ Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject, - Detail: a.Detail, URL: a.URL, Since: a.Since, + Detail: a.Detail, URL: a.URL, Ref: a.Ref, Since: a.Since, }) } return st diff --git a/internal/web/auto.go b/internal/web/auto.go new file mode 100644 index 0000000..e766fa3 --- /dev/null +++ b/internal/web/auto.go @@ -0,0 +1,93 @@ +package web + +import ( + "context" + "net/http" + "strings" + "sync" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +// maxAutoSends caps webhook calls per refresh so a first start cannot flood the consumer. +const maxAutoSends = 5 + +type autoKey struct{ action, repo, url, ref string } + +// AutoRunner fires auto actions once per (action, repo, url, head commit). +type AutoRunner struct { + actions *Actions + sent map[autoKey]struct{} + mu sync.Mutex +} + +// NewAutoRunner returns a runner for the auto actions in a, or nil when none is configured. +func NewAutoRunner(a *Actions) *AutoRunner { + for _, rt := range a.list { + if rt.cfg.Auto { + return &AutoRunner{actions: a, sent: map[autoKey]struct{}{}} + } + } + return nil +} + +// Run sends the webhooks that are due for the alerts in v and forgets the ones whose alert is gone. +func (r *AutoRunner) Run(ctx context.Context, v collector.View) { + r.mu.Lock() + defer r.mu.Unlock() + live := map[autoKey]struct{}{} + budget := maxAutoSends + for _, rt := range r.actions.list { + if !rt.cfg.Auto { + continue + } + for _, al := range v.Alerts { + if !rt.cfg.AppliesTo(al.Kind) || len(rt.cfg.Authors) > 0 && !rt.cfg.AuthorAllowed(al.Author) { + continue + } + k := autoKey{rt.cfg.ID, strings.ToLower(al.Repo), al.URL, al.Ref} + live[k] = struct{}{} + if _, done := r.sent[k]; done || budget == 0 || ctx.Err() != nil { + continue + } + budget-- + if r.send(ctx, rt, al, v) { + r.sent[k] = struct{}{} + } + } + } + for k := range r.sent { + if _, ok := live[k]; !ok { + delete(r.sent, k) + } + } +} + +// send posts one alert and reports whether it counts as delivered; 409 means busy and is retried. +func (r *AutoRunner) send(ctx context.Context, rt *actionRuntime, al model.Alert, v collector.View) bool { + a := r.actions + status, _, err := a.post(ctx, rt.cfg, newWebhookPayload(rt.cfg.ID, al, v.Now)) + switch { + case err != nil: + a.count(rt.cfg.ID, resultAutoFailed) + a.log.Warn("auto action webhook failed", "action", rt.cfg.ID, "repo", al.Repo, "err", err) + return false + case status == http.StatusConflict: + a.count(rt.cfg.ID, resultAutoBusy) + a.log.Info("auto action busy, will retry", "action", rt.cfg.ID, "repo", al.Repo) + return false + case status/100 == 2: + a.count(rt.cfg.ID, resultAutoOK) + rt.invalidate() + a.log.Info("auto action sent", "action", rt.cfg.ID, "repo", al.Repo, "ref", al.Ref) + return true + } + result := resultAutoFailed + if status/100 == 4 { + result = resultAutoRefused + } + a.count(rt.cfg.ID, result) + a.log.Warn("auto action webhook answered", "action", rt.cfg.ID, "repo", al.Repo, "status", status) + return false +} diff --git a/internal/web/auto_test.go b/internal/web/auto_test.go new file mode 100644 index 0000000..f550253 --- /dev/null +++ b/internal/web/auto_test.go @@ -0,0 +1,144 @@ +package web + +import ( + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +func autoPR(n int, sha, author string, mut func(*model.PullRequest)) model.PullRequest { + p := model.PullRequest{Number: n, Title: "t", URL: prURL + strings.Repeat("x", n), Author: author, HeadSHA: sha, CreatedAt: now.Add(-time.Hour)} + if mut != nil { + mut(&p) + } + return p +} + +// autoView builds the view through the real alert evaluation so fork and draft rules are exercised. +func autoView(prs ...model.PullRequest) collector.View { + r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: now, PRs: prs} + alerts := collector.Evaluate(&r, now, config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}) + return collector.View{Now: now, Repos: []model.Repo{r}, Alerts: alerts} +} + +type autoStep struct { + prs []model.PullRequest + code int + wantPosts int32 +} + +func TestAutoRunner(t *testing.T) { + fork := func(p *model.PullRequest) { p.Fork = true } + draft := func(p *model.PullRequest) { p.Draft = true } + tests := map[string]struct { + authors []string + steps []autoStep + }{ + "fork skipped": {steps: []autoStep{{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 0}}}, + "draft skipped": {steps: []autoStep{{[]model.PullRequest{autoPR(1, "a", "x", draft)}, 200, 0}}}, + "author filter": {authors: []string{"Alice"}, steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "bob", nil), autoPR(2, "b", "alice", nil)}, 200, 1}, + }}, + "once per sha": {steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0}, + }}, + "new sha resends": {steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + {[]model.PullRequest{autoPR(1, "b", "x", nil)}, 201, 1}, + }}, + "reappearing alert resends": {steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + {nil, 200, 0}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + }}, + "failure retries": {steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 500, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 400, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0}, + }}, + "409 retries": {steps: []autoStep{ + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 409, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1}, + {[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0}, + }}, + "cap per refresh": {steps: []autoStep{ + {manyPRs(8), 200, maxAutoSends}, + {manyPRs(8), 200, 3}, + {manyPRs(8), 200, 0}, + }}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + hook := newFakeHook(t) + a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true, Authors: tc.authors, + Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}}, + slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry()) + runner := NewAutoRunner(a) + for i, st := range tc.steps { + hook.postCode = st.code + before := hook.postCalls.Load() + runner.Run(t.Context(), autoView(st.prs...)) + if got := hook.postCalls.Load() - before; got != st.wantPosts { + t.Fatalf("step %d: posts = %d, want %d", i, got, st.wantPosts) + } + } + }) + } +} + +func manyPRs(n int) []model.PullRequest { + out := make([]model.PullRequest, n) + for i := range out { + out[i] = autoPR(i+1, "s", "x", nil) + } + return out +} + +func TestAutoRunnerPayloadAuthAndMetrics(t *testing.T) { + hook := newFakeHook(t) + a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true, + Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}}, + slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry()) + NewAutoRunner(a).Run(t.Context(), autoView(autoPR(1, "abc", "x", nil))) + hook.mu.Lock() + defer hook.mu.Unlock() + if hook.lastAuth != "Bearer "+testToken { + t.Fatalf("auth = %q", hook.lastAuth) + } + for _, want := range []string{`"action":"review"`, `"kind":"pr_open"`, `"ref":"abc"`, `"url":"` + prURL + `x"`} { + if !strings.Contains(hook.lastBody, want) { + t.Fatalf("body %s lacks %s", hook.lastBody, want) + } + } + if got := testutil.ToFloat64(a.counter.WithLabelValues("review", resultAutoOK)); got != 1 { + t.Fatalf("auto_ok = %v, want 1", got) + } +} + +func TestNewAutoRunnerNilWithoutAutoActions(t *testing.T) { + a := NewActions([]config.Action{{ID: "x", Webhook: config.Webhook{URL: "http://h.test"}}}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if NewAutoRunner(a) != nil { + t.Fatal("runner must be nil when no action is auto") + } +} + +func TestStateCarriesPROpenRef(t *testing.T) { + st := buildState(autoView(autoPR(1, "abc", "x", nil)), true) + if len(st.Alerts) != 1 || st.Alerts[0].Kind != collector.KindPROpen || st.Alerts[0].Ref != "abc" || st.Alerts[0].Severity != "ok" { + t.Fatalf("alerts = %+v", st.Alerts) + } + if st.Counts.Crit != 0 || st.Counts.Warn != 0 || st.Repos[0].Health != "ok" { + t.Fatalf("counts/health changed: %+v %s", st.Counts, st.Repos[0].Health) + } +} diff --git a/main.go b/main.go index e316c42..bc76520 100644 --- a/main.go +++ b/main.go @@ -81,7 +81,11 @@ func run(ctx context.Context, cfgPath string, log *slog.Logger) error { srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log) if len(cfg.Actions) > 0 { - srv.WithActions(web.NewActions(cfg.Actions, log, reg)) + actions := web.NewActions(cfg.Actions, log, reg) + srv.WithActions(actions) + if auto := web.NewAutoRunner(actions); auto != nil { + coll.OnRefresh(auto.Run) + } } go coll.Run(ctx)