mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-14 00:58:36 +00:00
feat(web): add automatic actions for open pull requests
A new pr_open alert carries the head commit SHA of each open, non-draft, same-repository pull request. An action with auto enabled sends its webhook once for each pull request and head commit. An authors list limits which pull requests trigger it. The alert never changes repository health.
This commit is contained in:
+19
-5
@@ -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()
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user