mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2100458973 | ||
|
|
6b08d09431 | ||
|
|
78a6723143 | ||
|
|
c2ce5391c0 | ||
|
|
c79ecb728f |
@@ -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, after every single-repository rescan, 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 including pull requests from forks, in a non-archived repo. Fork pull requests come from external contributors, so a consumer of these alerts, including an auto action webhook, must treat them as untrusted input. `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
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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 PR, forks included; consumers must treat fork PRs as untrusted
|
||||
# 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
|
||||
|
||||
@@ -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,36 @@ 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 ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.notify(ctx, c.View())
|
||||
}
|
||||
|
||||
func (c *Collector) notify(ctx context.Context, v 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() {
|
||||
@@ -224,7 +242,12 @@ func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error)
|
||||
c.mu.Lock()
|
||||
c.repos[canon] = r
|
||||
c.mu.Unlock()
|
||||
return c.View(), nil
|
||||
v := c.View()
|
||||
if len(c.onRefresh) > 0 {
|
||||
// The request context ends with the response, which must not wait for webhooks.
|
||||
go c.notify(context.WithoutCancel(ctx), v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (c *Collector) configured(name string) (string, bool) {
|
||||
|
||||
@@ -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,14 +86,14 @@ 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 {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := fresh()
|
||||
tt.mutate(&r)
|
||||
alerts := Evaluate(&r, t0, th())
|
||||
alerts := withoutPROpen(Evaluate(&r, t0, th()))
|
||||
got := kinds(alerts)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want)
|
||||
@@ -110,23 +110,34 @@ func TestEvaluate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// withoutPROpen drops the informational pr_open alerts so tests of other kinds stay focused.
|
||||
func withoutPROpen(in []model.Alert) []model.Alert {
|
||||
var out []model.Alert
|
||||
for _, a := range in {
|
||||
if a.Kind != KindPROpen {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
if a := withoutPROpen(Evaluate(&r, t0, off)); len(a) != 0 {
|
||||
t.Fatalf("disabled checks produced %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if a := withoutPROpen(Evaluate(&r, t0, cfg)); len(a) != 0 {
|
||||
t.Fatalf("unexpected %+v", a)
|
||||
}
|
||||
if a := Evaluate(&r, t0.Add(10*time.Hour), cfg); len(a) != 1 || a[0].Severity != model.Warn {
|
||||
if a := withoutPROpen(Evaluate(&r, t0.Add(10*time.Hour), cfg)); len(a) != 1 || a[0].Severity != model.Warn {
|
||||
t.Fatalf("PR must cross the warn threshold as time passes: %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -230,7 +241,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
|
||||
}}
|
||||
@@ -240,7 +251,8 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
||||
if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" {
|
||||
t.Fatalf("order: %s %s %s", v.Repos[0].FullName, v.Repos[1].FullName, v.Repos[2].FullName)
|
||||
}
|
||||
if len(v.Alerts) != 2 || v.Alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
|
||||
alerts := withoutPROpen(v.Alerts)
|
||||
if len(alerts) != 2 || alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
|
||||
t.Fatalf("alerts: %+v", v.Alerts)
|
||||
}
|
||||
}
|
||||
@@ -457,3 +469,87 @@ 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, true},
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoRunsRefreshHooksWithTheNewView(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, 3)}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
got := make(chan View, 1)
|
||||
c.OnRefresh(func(_ context.Context, v View) { got <- v })
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case v := <-got:
|
||||
if len(v.Repos) != 1 || len(v.Repos[0].Issues) != 3 {
|
||||
t.Fatalf("hook saw a stale view: %+v", v.Repos)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("refresh hook did not run after a repository refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
|
||||
called := make(chan struct{}, 1)
|
||||
c.OnRefresh(func(context.Context, View) { called <- struct{}{} })
|
||||
if _, err := c.RefreshRepo(context.Background(), "unknown/x"); err == nil {
|
||||
t.Fatal("unknown repository must fail")
|
||||
}
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("hook ran for a refused refresh")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 @@ 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)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+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,147 @@
|
||||
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 triggers once per sha": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 1},
|
||||
{[]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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Generated
+49
-49
@@ -25,7 +25,7 @@ importers:
|
||||
version: 3.3.3(@fortawesome/[email protected])([email protected]([email protected]))
|
||||
'@lucide/vue':
|
||||
specifier: ^1.49.0
|
||||
version: 1.49.0([email protected]([email protected]))
|
||||
version: 1.50.0([email protected]([email protected]))
|
||||
'@vueuse/core':
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.0([email protected]([email protected]))
|
||||
@@ -50,7 +50,7 @@ importers:
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1([email protected]1.0([email protected]))
|
||||
version: 10.0.1([email protected]2.0([email protected]))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3([email protected](@types/[email protected])([email protected]))
|
||||
@@ -65,10 +65,10 @@ importers:
|
||||
version: 2.5.1(@vue/[email protected])(@vue/[email protected])([email protected]([email protected]))
|
||||
eslint:
|
||||
specifier: ^10.11.0
|
||||
version: 10.11.0([email protected])
|
||||
version: 10.12.0([email protected])
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.11.1
|
||||
version: 10.11.1(@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected]))([email protected]1.0([email protected]))([email protected]([email protected]1.0([email protected])))
|
||||
version: 10.11.1(@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected]))([email protected]2.0([email protected]))([email protected]([email protected]2.0([email protected])))
|
||||
globals:
|
||||
specifier: ^17.12.0
|
||||
version: 17.13.0
|
||||
@@ -86,7 +86,7 @@ importers:
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.70.1
|
||||
version: 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
version: 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
vite:
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.2(@types/[email protected])([email protected])
|
||||
@@ -95,7 +95,7 @@ importers:
|
||||
version: 5.0.3(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))
|
||||
vue-tsc:
|
||||
specifier: ^3.3.11
|
||||
version: 3.3.11([email protected])
|
||||
version: 3.3.12([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
@@ -248,8 +248,8 @@ packages:
|
||||
'@keyv/[email protected]':
|
||||
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
|
||||
|
||||
'@lucide/vue@1.49.0':
|
||||
resolution: {integrity: sha512-7xS5gYnLoIx0jlmlRE3Rb71fIHaNqhVBiKMpOlqrYt6nMPy3C/62HJHo5XNsV3HTK+9l3M0hXICrIA7S5v6y5Q==}
|
||||
'@lucide/vue@1.50.0':
|
||||
resolution: {integrity: sha512-/cYAYqHIMUcVbhGYPk+GqgPqlqTKCFfyGrpXvH/6z5v7sWRoWBHViYwO3cNgmupZCBY+4WB016wN6CkXNcy0Yw==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -605,8 +605,8 @@ packages:
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
|
||||
|
||||
'@vue/[email protected]1':
|
||||
resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==}
|
||||
'@vue/[email protected]2':
|
||||
resolution: {integrity: sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==}
|
||||
|
||||
'@vue/[email protected]':
|
||||
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||
@@ -806,8 +806,8 @@ packages:
|
||||
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
[email protected]1.0:
|
||||
resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==}
|
||||
[email protected]2.0:
|
||||
resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1435,8 +1435,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
[email protected]1:
|
||||
resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==}
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
@@ -1514,9 +1514,9 @@ snapshots:
|
||||
hashery: 1.5.1
|
||||
keyv: 5.6.0
|
||||
|
||||
'@eslint-community/[email protected]([email protected]1.0([email protected]))':
|
||||
'@eslint-community/[email protected]([email protected]2.0([email protected]))':
|
||||
dependencies:
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/[email protected]': {}
|
||||
@@ -1537,9 +1537,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/[email protected]([email protected]1.0([email protected]))':
|
||||
'@eslint/[email protected]([email protected]2.0([email protected]))':
|
||||
optionalDependencies:
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
|
||||
'@eslint/[email protected]': {}
|
||||
|
||||
@@ -1638,7 +1638,7 @@ snapshots:
|
||||
|
||||
'@keyv/[email protected]': {}
|
||||
|
||||
'@lucide/vue@1.49.0([email protected]([email protected]))':
|
||||
'@lucide/vue@1.50.0([email protected]([email protected]))':
|
||||
dependencies:
|
||||
vue: 3.5.43([email protected])
|
||||
|
||||
@@ -1797,15 +1797,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 26.6.4
|
||||
|
||||
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected]))([email protected]1.0([email protected]))([email protected])':
|
||||
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected]))([email protected]2.0([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
'@typescript-eslint/scope-manager': 8.71.0
|
||||
'@typescript-eslint/type-utils': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/type-utils': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.71.0
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
ignore: 7.0.11
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0([email protected])
|
||||
@@ -1813,14 +1813,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected])':
|
||||
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.71.0
|
||||
'@typescript-eslint/types': 8.71.0
|
||||
'@typescript-eslint/typescript-estree': 8.71.0([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.71.0
|
||||
debug: 4.4.3
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -1843,13 +1843,13 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected])':
|
||||
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.71.0
|
||||
'@typescript-eslint/typescript-estree': 8.71.0([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
debug: 4.4.3
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
ts-api-utils: 2.5.0([email protected])
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -1872,13 +1872,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected])':
|
||||
'@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]1.0([email protected]))
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
|
||||
'@typescript-eslint/scope-manager': 8.71.0
|
||||
'@typescript-eslint/types': 8.71.0
|
||||
'@typescript-eslint/typescript-estree': 8.71.0([email protected])
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -1962,7 +1962,7 @@ snapshots:
|
||||
|
||||
'@vue/[email protected]': {}
|
||||
|
||||
'@vue/[email protected]1':
|
||||
'@vue/[email protected]2':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.43
|
||||
@@ -2129,18 +2129,18 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected]))([email protected]1.0([email protected]))([email protected]([email protected]1.0([email protected]))):
|
||||
[email protected](@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected]))([email protected]2.0([email protected]))([email protected]([email protected]2.0([email protected]))):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]1.0([email protected]))
|
||||
eslint: 10.11.0([email protected])
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
|
||||
eslint: 10.12.0([email protected])
|
||||
natural-compare: 1.4.0
|
||||
nth-check: 2.1.1
|
||||
postcss-selector-parser: 7.1.6
|
||||
semver: 7.8.5
|
||||
vue-eslint-parser: 10.4.1([email protected]1.0([email protected]))
|
||||
vue-eslint-parser: 10.4.1([email protected]2.0([email protected]))
|
||||
xml-name-validator: 5.0.0
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -2153,9 +2153,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]1.0([email protected]):
|
||||
[email protected]2.0([email protected]):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]1.0([email protected]))
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.23.5
|
||||
'@eslint/config-helpers': 0.7.0
|
||||
@@ -2603,13 +2603,13 @@ snapshots:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
[email protected]([email protected]1.0([email protected]))([email protected]):
|
||||
[email protected]([email protected]2.0([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/[email protected]([email protected]1.0([email protected]))([email protected]))([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
'@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/[email protected]([email protected]2.0([email protected]))([email protected]))([email protected]2.0([email protected]))([email protected])
|
||||
'@typescript-eslint/parser': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
'@typescript-eslint/typescript-estree': 8.71.0([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))([email protected])
|
||||
eslint: 10.11.0([email protected])
|
||||
'@typescript-eslint/utils': 8.71.0([email protected]2.0([email protected]))([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2666,10 +2666,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.43([email protected])
|
||||
|
||||
[email protected]([email protected]1.0([email protected])):
|
||||
[email protected]([email protected]2.0([email protected])):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
eslint: 10.11.0([email protected])
|
||||
eslint: 10.12.0([email protected])
|
||||
eslint-scope: 9.1.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
espree: 11.2.0
|
||||
@@ -2678,10 +2678,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]1([email protected]):
|
||||
[email protected]2([email protected]):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28([email protected])
|
||||
'@vue/language-core': 3.3.11
|
||||
'@vue/language-core': 3.3.12
|
||||
typescript: 5.9.3
|
||||
|
||||
[email protected]([email protected]):
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "pnpm 11 cannot switch to the pnpm 12 native binary: npx runs it with node",
|
||||
"matchDepTypes": ["packageManager"],
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"allowedVersions": "<12"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user