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:
2026-10-05 00:41:08 +01:00
parent 23bbbedf59
commit c79ecb728f
15 changed files with 473 additions and 27 deletions
+24 -1
View File
@@ -94,6 +94,29 @@ actions:
- A `link` that is not an http or https address is dropped. - 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. - 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 <age>`. 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 ## Metrics
All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. 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 | | `release_info`, `release_published_timestamp_seconds` | repo, version, url |
| `alerts` | severity | | `alerts` | severity |
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none | | `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: Example alert rules:
+7
View File
@@ -25,6 +25,13 @@ thresholds: # negative disables a check
# timeout: 10s # timeout: 10s
# status_url: https://example.internal/hooks/rerun/status # optional # status_url: https://example.internal/hooks/rerun/status # optional
# confirm: "Run this action for the selected alert?" # optional; omit for no dialog # 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 max_items: 100 # per repo, issues and PRs
concurrency: 2 concurrency: 2
+18 -3
View File
@@ -65,6 +65,7 @@ type Collector struct {
log *slog.Logger log *slog.Logger
webBase string webBase string
kick chan struct{} kick chan struct{}
onRefresh []func(context.Context, View)
names []string names []string
th config.Thresholds th config.Thresholds
interval time.Duration 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) { func (c *Collector) Run(ctx context.Context) {
t := time.NewTicker(c.interval) t := time.NewTicker(c.interval)
defer t.Stop() defer t.Stop()
c.Refresh(ctx) c.refreshAndNotify(ctx)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-t.C: case <-t.C:
c.Refresh(ctx) c.refreshAndNotify(ctx)
case <-c.kick: 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. // RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued.
func (c *Collector) RequestRefresh() bool { func (c *Collector) RequestRefresh() bool {
if c.refreshing.Load() { if c.refreshing.Load() {
+58 -8
View File
@@ -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)}}} 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}, }, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
"pr fresh": {func(r *model.Repo) { "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}, }, map[string]model.Severity{}, model.OK},
"pr waiting warn": {func(r *model.Repo) { "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}, }, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn},
"pr waiting crit": {func(r *model.Repo) { "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}, }, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
"draft pr ignored": {func(r *model.Repo) { "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}}} 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}, }, map[string]model.Severity{}, model.OK},
"pr checks failing": {func(r *model.Repo) { "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}, }, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
"issues stale aggregated": {func(r *model.Repo) { "issues stale aggregated": {func(r *model.Repo) {
old := t0.Add(-40 * 24 * time.Hour) 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}, }, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
"archived repo skips work alerts": {func(r *model.Repo) { "archived repo skips work alerts": {func(r *model.Repo) {
r.Archived = true 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}, }, map[string]model.Severity{}, model.OK},
} }
for name, tt := range tests { for name, tt := range tests {
@@ -111,7 +111,7 @@ func TestEvaluate(t *testing.T) {
} }
func TestEvaluateDisabledThresholds(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)}}} Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1} off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
if a := Evaluate(&r, t0, off); len(a) != 0 { if a := Evaluate(&r, t0, off); len(a) != 0 {
@@ -120,7 +120,7 @@ func TestEvaluateDisabledThresholds(t *testing.T) {
} }
func TestAgeAdvancesWithoutRefresh(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 := th()
cfg.DataStaleAfter = -1 cfg.DataStaleAfter = -1
if a := Evaluate(&r, t0, cfg); len(a) != 0 { 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"} c := &model.Run{Status: "completed", Conclusion: "failure"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}} r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
case "o/warn": 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 return r, nil
}} }}
@@ -457,3 +457,53 @@ func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
t.Fatalf("%+v", c.View().Repos) 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)
}
}
+6
View File
@@ -20,6 +20,8 @@ const (
KindPRWaiting = "pr_waiting" KindPRWaiting = "pr_waiting"
KindPRChecks = "pr_checks_failing" KindPRChecks = "pr_checks_failing"
KindIssuesStale = "issues_stale" 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. // 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) age := now.Sub(pr.CreatedAt)
subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title) 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 { switch {
case th.PRCritAfter > 0 && age >= th.PRCritAfter: case th.PRCritAfter > 0 && age >= th.PRCritAfter:
add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt) add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
+20
View File
@@ -45,6 +45,23 @@ type Action struct {
Webhook Webhook `yaml:"webhook"` Webhook Webhook `yaml:"webhook"`
// Kinds limits the action to these alert kinds; empty means every kind. // Kinds limits the action to these alert kinds; empty means every kind.
Kinds []string `yaml:"kinds"` 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 { type Webhook struct {
@@ -196,6 +213,9 @@ func finalizeActions(actions []Action, getenv func(string) string) error {
if a.StatusURL != "" && !isHTTPURL(a.StatusURL) { 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) 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 { if a.Webhook.Timeout < 0 {
return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID) return fmt.Errorf("actions[%s].webhook.timeout must not be negative", a.ID)
} }
+23
View File
@@ -177,6 +177,8 @@ func TestActionsFinalize(t *testing.T) {
"status url ok": {mutate: func(a *Action) { a.StatusURL = "http://status.example.test/s" }}, "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}, "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}, "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 { for name, tc := range tests {
t.Run(name, func(t *testing.T) { 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)
}
})
}
}
+15 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"slices" "slices"
"strings"
"time" "time"
"github.com/lukaszraczylo/ghtracker/internal/model" "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"` HTMLURL string `json:"html_url"`
User userRef `json:"user"` User userRef `json:"user"`
Head struct { Head struct {
Repo *repoRef `json:"repo"`
SHA string `json:"sha"` SHA string `json:"sha"`
} `json:"head"` } `json:"head"`
Base struct {
Repo *repoRef `json:"repo"`
} `json:"base"`
Labels []labelRef `json:"labels"` Labels []labelRef `json:"labels"`
Number int `json:"number"` Number int `json:"number"`
Draft bool `json:"draft"` Draft bool `json:"draft"`
@@ -141,13 +146,22 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest,
for _, p := range items { for _, p := range items {
out = append(out, model.PullRequest{ out = append(out, model.PullRequest{
Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft, 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}, Checks: model.CheckSummary{State: model.ChecksUnknown},
}) })
} }
return out, err 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. // 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 { func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error {
var first error var first error
+26
View File
@@ -553,3 +553,29 @@ func TestFreshClientDisablesConnectionReuse(t *testing.T) {
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } 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)
}
})
}
}
+6
View File
@@ -90,6 +90,8 @@ type PullRequest struct {
Checks CheckSummary Checks CheckSummary
Number int Number int
Draft bool Draft bool
// Fork is true when the head repository differs from the base or was deleted.
Fork bool
} }
type CheckSummary struct { type CheckSummary struct {
@@ -139,6 +141,10 @@ type Alert struct {
Subject string Subject string
Detail string Detail string
URL 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 Severity Severity
} }
+19 -5
View File
@@ -29,6 +29,10 @@ const (
resultOK = "ok" resultOK = "ok"
resultRejected = "rejected" resultRejected = "rejected"
resultFailed = "error" resultFailed = "error"
resultAutoOK = "auto_ok"
resultAutoBusy = "auto_busy"
resultAutoRefused = "auto_rejected"
resultAutoFailed = "auto_error"
actionMetricName = "action_requests_total" actionMetricName = "action_requests_total"
contentTypeHeader = "Content-Type" 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} { for _, r := range []string{resultOK, resultRejected, resultFailed} {
a.counter.WithLabelValues(c.ID, r) 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 { if reg != nil {
reg.MustRegister(a.counter) reg.MustRegister(a.counter)
@@ -221,6 +230,7 @@ type webhookAlert struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Detail string `json:"detail"` Detail string `json:"detail"`
URL string `json:"url"` URL string `json:"url"`
Ref string `json:"ref,omitempty"`
} }
type webhookPayload struct { type webhookPayload struct {
@@ -250,11 +260,7 @@ func (s *Server) runAction(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, code, msg) writeJSONError(w, code, msg)
return return
} }
status, body, err := s.actions.post(r.Context(), rt.cfg, webhookPayload{ status, body, err := s.actions.post(r.Context(), rt.cfg, newWebhookPayload(rt.cfg.ID, alert, s.now()))
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},
})
if err != nil { if err != nil {
s.actions.count(rt.cfg.ID, resultFailed) s.actions.count(rt.cfg.ID, resultFailed)
s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err) 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)) _ = 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. // 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) { func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) {
v := s.src.View() v := s.src.View()
+2 -1
View File
@@ -95,6 +95,7 @@ type apiAlert struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Detail string `json:"detail"` Detail string `json:"detail"`
URL string `json:"url"` URL string `json:"url"`
Ref string `json:"ref,omitempty"`
} }
func seconds(d time.Duration) int64 { return int64(d / time.Second) } 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 { for _, a := range v.Alerts {
st.Alerts = append(st.Alerts, apiAlert{ st.Alerts = append(st.Alerts, apiAlert{
Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject, 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 return st
+93
View File
@@ -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
}
+144
View File
@@ -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)
}
}
+5 -1
View File
@@ -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) srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log)
if len(cfg.Actions) > 0 { 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) go coll.Run(ctx)