Compare commits

..
19 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub d65fed1546 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-11 03:21:14 +00:00
github-actions[bot]andGitHub 4b9b6303de chore(deps): update module golang.org/x/sys to v0.49.0
Automated dependency update. All tests passed.
2026-10-10 03:19:18 +00:00
github-actions[bot]andGitHub 021dc2e8f9 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-09 03:20:39 +00:00
github-actions[bot]andGitHub fb31e31bec chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-08 03:19:51 +00:00
github-actions[bot]andGitHub 0585326fea chore(deps): update dependency @lucide/vue to v1.52.0
Automated dependency update. All tests passed.
2026-10-07 03:19:22 +00:00
lukaszraczylo 5bca4c38f0 feat(alerts): raise one alert per open issue and pull request
Each open issue now has its own alert. An issue with recent activity is issue_fresh. An issue idle for issue_stale_after or longer is issue_stale. This replaces the single issues_stale line per repo.

A non-draft pull request younger than pr_warn_after now raises pr_fresh. Older pull requests keep pr_waiting.

All new alerts have warn severity, so a repo with open work is no longer green. The pr_open alert for auto actions is unchanged.
2026-10-06 14:14:26 +01:00
github-actions[bot]andGitHub eff9559aee chore(deps): update pnpm to v11.28.4
Automated dependency update. All tests passed.
2026-10-06 03:16:32 +00:00
lukaszraczylo 5c5416f6f0 fix(web): keep the action status fetch alive when a client disconnects
The status fetch used the request context. A client that dropped the
connection, such as a readiness probe with a one second timeout,
cancelled the fetch. The failure was then cached and every later
request showed no action status. The fetch now ignores the caller
cancellation and keeps its own timeout.
2026-10-05 15:53:52 +01:00
lukaszraczylo a0fd6d6856 feat(ui): make the alert action button prominent
The action button was a small outline control that was easy to miss.
It is now a solid primary button with an icon. The spinner still
replaces the icon while the action runs.
2026-10-05 14:05:12 +01:00
lukaszraczylo ac5eb2f16a fix(ui): stack the project list below 1280px and use two columns from 90rem
At 1024px the project list took 24rem and squeezed the alert column to
about 500px, which clipped the search box and filter counts. The list
now sits beside the alerts from 1280px. The alert cards use two columns
from 90rem, so a 1440px screen no longer shows one very wide column.
2026-10-05 14:05:12 +01:00
lukaszraczylo 66ed84bdbf fix(collector): publish each repository as its scan finishes
A rescan held every result until the last repository finished. The
dashboard therefore showed new and resolved alerts only at the end.
Each repository now enters the view when its fetch completes. The
last scan time, loaded flag and refresh hooks still update at the end.
2026-10-05 14:05:12 +01:00
lukaszraczylo e6f28a223a fix(web): move to TypeScript 6 without baseUrl 2026-10-05 11:50:37 +01:00
lukaszraczylo 0a2469ef7d fix(gh): bypass the stale run cache with a created filter
GitHub answered the confirming run query with a weeks-old cached result
even with page=1, so alerts for fixed workflows stayed. A created filter
changes the query each day and reads current runs.
2026-10-05 11:16:37 +01:00
github-actions[bot]andGitHub 06922abe05 chore(deps): update dependency @lucide/vue to v1.51.0
Automated dependency update. All tests passed.
2026-10-05 09:37:40 +00:00
lukaszraczylo 2100458973 chore(renovate): hold pnpm below 12
The pinned pnpm 11 in workflow-prepare.sh and ci.yaml cannot switch to the
pnpm 12 native binary. Node runs the binary as JavaScript and the install fails.
2026-10-05 10:34:16 +01:00
github-actions[bot]andGitHub 6b08d09431 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-05 03:22:21 +00:00
lukaszraczylo 78a6723143 fix(web): run automatic actions after a repository rescan
The rescan button of one repository now triggers automatic actions too. The webhook calls run in the background so the response does not wait for them.
2026-10-05 01:57:05 +01:00
lukaszraczylo c2ce5391c0 fix(collector): include fork pull requests in pr_open
External contributors open pull requests from forks. The pr_open alert now covers them, so the review action sees them. The consumer must treat fork pull requests as untrusted.
2026-10-05 01:00:18 +01:00
lukaszraczylo c79ecb728f 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.
2026-10-05 00:41:08 +01:00
30 changed files with 886 additions and 321 deletions
+40 -15
View File
@@ -17,11 +17,11 @@ Release archives and the image checksums are signed with cosign.
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
- Failing workflows, including scheduled and other background ones, with a link to the run.
- Pull requests that wait too long or fail their checks, with links and age.
- Stale issues, as one line per project.
- Pull requests, one line each: new ones (`pr_fresh`), ones that wait too long (`pr_waiting`) and ones that fail their checks.
- Open issues, one line each: new ones (`issue_fresh`) and ones without recent activity (`issue_stale`).
- Projects that failed to refresh, or loaded only in part.
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Healthy items do not appear anywhere else.
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Every open non-draft pull request and every open issue raises a warning, so a project with open work is never green.
### Search and filters
@@ -48,7 +48,9 @@ A side panel lists every project with its latest release (or newest tag), a link
| Non-draft PR open longer than `pr_warn_after` | warn |
| PR checks failing | warn |
| Workflow queued or running longer than `workflow_stuck_after` | warn |
| Open issues without activity for `issue_stale_after` (one alert per repo) | warn |
| Non-draft PR open less than `pr_warn_after` (`pr_fresh`) | warn |
| Open issue without activity for `issue_stale_after` (`issue_stale`, one alert per issue) | warn |
| Open issue with recent activity (`issue_fresh`, one alert per issue) | warn |
| A section failed to load, for example a missing App permission | warn |
| Data older than `data_stale_after` | warn |
@@ -94,21 +96,44 @@ 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.
| Metric | Labels |
| -------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
| `pr_age_seconds`, `pr_checks_failing` | repo, number |
| `workflow_failing`, `workflow_running`, `workflow_last_run_timestamp_seconds` | repo, workflow, background |
| `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 |
| Metric | Labels |
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
| `pr_age_seconds`, `pr_checks_failing` | repo, number |
| `workflow_failing`, `workflow_running`, `workflow_last_run_timestamp_seconds` | repo, workflow, background |
| `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`, plus `auto_*` for auto actions; only with `actions`) | action, result |
Example alert rules:
+7
View File
@@ -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
+2 -2
View File
@@ -3,7 +3,7 @@ module github.com/lukaszraczylo/ghtracker
go 1.26.0
require (
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_golang v1.25.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -15,6 +15,6 @@ require (
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/sys v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+6 -6
View File
@@ -4,14 +4,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
@@ -26,8 +26,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+33 -10
View File
@@ -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() {
@@ -137,25 +155,25 @@ func (c *Collector) Refresh(ctx context.Context) {
c.limited.Store(false)
c.scanDone.Store(0)
c.scanTotal.Store(int64(len(c.names)))
results := make([]model.Repo, len(c.names))
sem := make(chan struct{}, c.concurrency)
var wg sync.WaitGroup
for i, name := range c.names {
for _, name := range c.names {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
results[i] = c.refreshOne(ctx, name, start, true)
r := c.refreshOne(ctx, name, start, true)
// Publish per repo so a running scan shows new and resolved alerts as they land.
c.mu.Lock()
c.repos[r.FullName] = r
c.mu.Unlock()
c.scanDone.Add(1)
}()
}
wg.Wait()
c.mu.Lock()
for _, r := range results {
c.repos[r.FullName] = r
}
c.lastRefresh = start
c.lastDur = c.now().Sub(start)
c.loaded = true
@@ -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) {
+160 -22
View File
@@ -66,34 +66,36 @@ 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)}}
}, map[string]model.Severity{}, model.OK},
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-47 * time.Hour)}}
}, map[string]model.Severity{KindPRFresh: model.Warn}, model.Warn},
"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"}}}}
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
"issues stale aggregated": {func(r *model.Repo) {
old := t0.Add(-40 * 24 * time.Hour)
r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}}
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
}, map[string]model.Severity{KindPRFresh: model.Warn, KindPRChecks: model.Warn}, model.Warn},
"issue stale": {func(r *model.Repo) {
r.Issues = []model.Issue{{Number: 1, CreatedAt: t0.Add(-60 * 24 * time.Hour), UpdatedAt: t0.Add(-40 * 24 * time.Hour)}}
}, map[string]model.Severity{KindIssueStale: model.Warn}, model.Warn},
"issue fresh": {func(r *model.Repo) {
r.Issues = []model.Issue{{Number: 1, CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0}}
}, map[string]model.Severity{KindIssueFresh: 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,24 +112,37 @@ 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 {
t.Fatalf("disabled checks produced %+v", a)
for _, a := range withoutPROpen(Evaluate(&r, t0, off)) {
if a.Kind != KindPRFresh && a.Kind != KindIssueFresh {
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 {
t.Fatalf("unexpected %+v", a)
if a := withoutPROpen(Evaluate(&r, t0, cfg)); len(a) != 1 || a[0].Kind != KindPRFresh {
t.Fatalf("a PR below the warn threshold must be fresh: %+v", a)
}
if a := 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)
if a := withoutPROpen(Evaluate(&r, t0.Add(10*time.Hour), cfg)); len(a) != 1 || a[0].Kind != KindPRWaiting {
t.Fatalf("PR must turn from fresh to waiting as time passes: %+v", a)
}
}
@@ -230,7 +245,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 +255,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 +473,125 @@ 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 !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 alerts = onlyPROpen(alerts); len(alerts) != 1 || alerts[0] != want {
t.Fatalf("alerts = %+v, want [%+v]", alerts, want)
}
})
}
}
func onlyPROpen(in []model.Alert) []model.Alert {
var out []model.Alert
for _, a := range in {
if a.Kind == KindPROpen {
out = append(out, a)
}
}
return out
}
func TestPROpenHasNoSeverityOfItsOwn(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(onlyPROpen(v.Alerts)) != 1 || v.Alerts[0].Severity != model.Warn || v.Crit != 0 || v.Warn != 1 {
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):
}
}
func TestRefreshPublishesEachRepoBeforeTheScanEnds(t *testing.T) {
release := make(chan struct{})
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if name == "o/slow" {
<-release
}
return model.Repo{FullName: name, Err: ""}, nil
}}
cfg := &config.Config{Repos: []string{"o/fast", "o/slow"}, RefreshInterval: time.Hour, Concurrency: 2, Thresholds: th()}
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
done := make(chan struct{})
go func() { c.Refresh(context.Background()); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if v := c.View(); len(v.Repos) == 1 && v.Repos[0].FullName == "o/fast" {
break
}
time.Sleep(5 * time.Millisecond)
}
v := c.View()
if len(v.Repos) != 1 || v.Repos[0].FullName != "o/fast" || !v.Refreshing || v.Loaded {
t.Fatalf("mid-scan view: repos=%d refreshing=%v loaded=%v", len(v.Repos), v.Refreshing, v.Loaded)
}
close(release)
<-done
if v := c.View(); len(v.Repos) != 2 || !v.Loaded {
t.Fatalf("after scan: repos=%d loaded=%v", len(v.Repos), v.Loaded)
}
}
+15 -16
View File
@@ -19,7 +19,11 @@ const (
KindWorkflowStuck = "workflow_stuck"
KindPRWaiting = "pr_waiting"
KindPRChecks = "pr_checks_failing"
KindIssuesStale = "issues_stale"
KindPRFresh = "pr_fresh"
KindIssueStale = "issue_stale"
KindIssueFresh = "issue_fresh"
// 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,32 +66,27 @@ 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)
case th.PRWarnAfter > 0 && age >= th.PRWarnAfter:
add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
default:
add(model.Warn, KindPRFresh, subject, "new, open "+Age(age), pr.URL, pr.CreatedAt)
}
if pr.Checks.State == model.ChecksFail {
add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt)
}
}
if th.IssueStaleAfter > 0 {
var stale int
var oldest time.Time
for _, is := range r.Issues {
if now.Sub(is.UpdatedAt) >= th.IssueStaleAfter {
stale++
if oldest.IsZero() || is.UpdatedAt.Before(oldest) {
oldest = is.UpdatedAt
}
}
}
if stale > 0 {
add(model.Warn, KindIssuesStale, fmt.Sprintf("%d stale issues", stale),
"no activity for "+Age(th.IssueStaleAfter)+" or more",
r.URL+"/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc", oldest)
for _, is := range r.Issues {
subject := fmt.Sprintf("#%d %s", is.Number, is.Title)
if idle := now.Sub(is.UpdatedAt); th.IssueStaleAfter > 0 && idle >= th.IssueStaleAfter {
add(model.Warn, KindIssueStale, subject, "no activity for "+Age(idle), is.URL, is.UpdatedAt)
} else {
add(model.Warn, KindIssueFresh, subject, "new, open "+Age(now.Sub(is.CreatedAt)), is.URL, is.CreatedAt)
}
}
return alerts
+20
View File
@@ -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)
}
+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" }},
"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)
}
})
}
}
+30 -12
View File
@@ -7,20 +7,23 @@ import (
"fmt"
"net/url"
"slices"
"strings"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
maxFailingNames = 5
runsPerWorkflow = 5
maxWorkflows = 100
eventPullRequest = "pull_request"
eventPullReqTgt = "pull_request_target"
statusStateFail = "failure"
statusStateError = "error"
statusStatePend = "pending"
maxFailingNames = 5
runsPerWorkflow = 5
// freshRunsWindowDays bounds the created filter of the confirming run query.
freshRunsWindowDays = 30
maxWorkflows = 100
eventPullRequest = "pull_request"
eventPullReqTgt = "pull_request_target"
statusStateFail = "failure"
statusStateError = "error"
statusStatePend = "pending"
)
// backgroundEvents are triggers that run without a person pushing code.
@@ -129,8 +132,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 +148,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
@@ -360,9 +376,11 @@ func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON
if fresh {
// GitHub can keep answering one exact query from an old cached result, for weeks, whatever
// the connection. A different query string skips that entry and reads current runs.
// GitHub can keep answering a branch query from an old cached result, for weeks, whatever
// the connection, and page=1 alone does not skip it. A created filter changes the query
// every day and reads current runs.
q.Set("page", "1")
q.Set("created", ">="+c.now().UTC().AddDate(0, 0, -freshRunsWindowDays).Format(time.DateOnly))
get = c.getJSONFresh
}
if err := get(ctx, full, path, q, &out); err != nil {
+28 -2
View File
@@ -461,12 +461,12 @@ func TestAPIErrorMessageParsing(t *testing.T) {
}
// staleThenFresh models GitHub answering one exact query from an old cached result: the plain
// query returns an old failing run every time, and the same query with page=1 returns current runs.
// query returns an old failing run every time, and the same query with a created filter returns current runs.
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Query().Get("page") == "" {
if r.URL.Query().Get("created") == "" {
_, _ = w.Write([]byte(`{"workflow_runs":[
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return
@@ -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
View File
@@ -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
}
+21 -6
View File
@@ -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)
@@ -149,7 +158,8 @@ func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertR
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
return rt.rows, rt.err
}
rows, err := a.fetchStatus(ctx, rt.cfg)
// A client that disconnects mid-request must not fail the fetch that every later request reuses.
rows, err := a.fetchStatus(context.WithoutCancel(ctx), rt.cfg)
if err != nil {
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
}
@@ -221,6 +231,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 +261,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 +293,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()
+13
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
@@ -407,3 +408,15 @@ func TestRedirectDoesNotForwardToken(t *testing.T) {
t.Fatal("bearer token forwarded across a redirect")
}
}
func TestStatusFetchSurvivesCancelledRequest(t *testing.T) {
fh := newFakeHook(t)
fh.statBody = `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`
h := newActionServer(t, nil, fh.srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
rows, err := h.actions.statusRows(ctx, h.actions.list[0])
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%d err=%v, want the fetch to complete despite the cancelled request", len(rows), err)
}
}
+2 -1
View File
@@ -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
+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
}
+147
View File
@@ -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) != 2 || st.Alerts[0].Kind != collector.KindPROpen || st.Alerts[0].Ref != "abc" || st.Alerts[0].Severity != "ok" || st.Alerts[1].Kind != collector.KindPRFresh {
t.Fatalf("alerts = %+v", st.Alerts)
}
if st.Repos[0].Health != "warn" {
t.Fatalf("health = %s, want warn", 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)
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)
+2 -2
View File
@@ -37,11 +37,11 @@
"happy-dom": "^20.14.5",
"prettier": "^3.9.9",
"tailwindcss": "^4.3.3",
"typescript": "~5.9.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vitest": "^5.0.1",
"vue-tsc": "^3.3.11"
},
"packageManager": "[email protected].3"
"packageManager": "[email protected].5"
}
+196 -205
View File
@@ -22,13 +22,13 @@ importers:
version: 7.3.1
'@fortawesome/vue-fontawesome':
specifier: ^3.3.3
version: 3.3.3(@fortawesome/[email protected])([email protected](typescript@5.9.3))
version: 3.3.3(@fortawesome/[email protected])([email protected](typescript@6.0.3))
'@lucide/vue':
specifier: ^1.49.0
version: 1.49.0([email protected](typescript@5.9.3))
version: 1.53.0([email protected](typescript@6.0.3))
'@vueuse/core':
specifier: ^15.0.0
version: 15.0.0([email protected](typescript@5.9.3))
version: 15.0.0([email protected](typescript@6.0.3))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -37,44 +37,44 @@ importers:
version: 2.1.1
pinia:
specifier: ^4.0.3
version: 4.0.3(@vue/[email protected])(typescript@5.9.3)([email protected](typescript@5.9.3))
version: 4.0.3(@vue/[email protected])(typescript@6.0.3)([email protected](typescript@6.0.3))
reka-ui:
specifier: ^2.10.5
version: 2.10.5([email protected](typescript@5.9.3))
version: 2.11.0([email protected](typescript@6.0.3))
tailwind-merge:
specifier: ^3.7.0
version: 3.7.0
vue:
specifier: ^3.5.43
version: 3.5.43(typescript@5.9.3)
version: 3.5.43(typescript@6.0.3)
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].2(@types/[email protected])([email protected]))
version: 4.3.3([email protected].4(@types/[email protected])([email protected]))
'@types/node':
specifier: ^26.6.2
version: 26.6.4
'@vitejs/plugin-vue':
specifier: ^6.0.9
version: 6.0.9([email protected].2(@types/[email protected])([email protected]))([email protected](typescript@5.9.3))
version: 6.0.9([email protected].4(@types/[email protected])([email protected]))([email protected](typescript@6.0.3))
'@vue/test-utils':
specifier: ^2.5.1
version: 2.5.1(@vue/[email protected])(@vue/[email protected])([email protected](typescript@5.9.3))
version: 2.5.1(@vue/[email protected])(@vue/[email protected])([email protected](typescript@6.0.3))
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].0([email protected]1.0([email protected]))(typescript@5.9.3))([email protected]1.0([email protected]))([email protected]([email protected]1.0([email protected])))
version: 10.11.1(@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3))([email protected]2.0([email protected]))([email protected]([email protected]2.0([email protected])))
globals:
specifier: ^17.12.0
version: 17.13.0
happy-dom:
specifier: ^20.14.5
version: 20.14.5
version: 20.14.6
prettier:
specifier: ^3.9.9
version: 3.9.9
@@ -82,20 +82,20 @@ importers:
specifier: ^4.3.3
version: 4.3.3
typescript:
specifier: ~5.9.3
version: 5.9.3
specifier: ~6.0.3
version: 6.0.3
typescript-eslint:
specifier: ^8.70.1
version: 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
version: 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
vite:
specifier: ^8.3.1
version: 8.3.2(@types/[email protected])([email protected])
version: 8.3.4(@types/[email protected])([email protected])
vitest:
specifier: ^5.0.1
version: 5.0.3(@types/[email protected])([email protected].5)([email protected].2(@types/[email protected])([email protected]))
version: 5.0.3(@types/[email protected])([email protected].6)([email protected].4(@types/[email protected])([email protected]))
vue-tsc:
specifier: ^3.3.11
version: 3.3.11(typescript@5.9.3)
version: 3.3.12(typescript@6.0.3)
packages:
@@ -170,8 +170,10 @@ packages:
'@floating-ui/[email protected]':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@floating-ui/vue@2.0.1':
resolution: {integrity: sha512-043dyEKD9TDSfz080YH2vobubgg+NIo/9Fw4RAl7sEMpD8Am1DNrrSTl/GpMBjyGHoD/FbJY2QLawY+HSQsEIA==}
peerDependencies:
vue: '>=3.3.0'
'@fontsource-variable/[email protected]':
resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==}
@@ -248,8 +250,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.53.0':
resolution: {integrity: sha512-WtBYxiwNVd9yP/UKeMbxAntZqrd50pWxhkXrxxOcYJc4XhsO0pkKbUed+sEh08C1u07zj5I3Rz6AU1v9wHEDqA==}
peerDependencies:
vue: '>=3.0.1'
@@ -490,63 +492,63 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.71.0
'@typescript-eslint/parser': ^8.71.1
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/[email protected]':
@@ -605,8 +607,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 +808,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:
@@ -897,8 +899,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
[email protected].5:
resolution: {integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==}
[email protected].6:
resolution: {integrity: sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==}
engines: {node: '>=20.0.0'}
[email protected]:
@@ -1222,6 +1224,10 @@ packages:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1242,8 +1248,8 @@ packages:
resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
engines: {node: '>=20'}
[email protected]0.5:
resolution: {integrity: sha512-/8o0y9BThE0hcHKFg+3770tWcJh3qKdOEFxLOPb9HIajXeFoH/J530cZH62jUJunbqwBgpSBL9v7nB8LJ5pDXA==}
[email protected]1.0:
resolution: {integrity: sha512-ilkpm4bIAol4jwzm4tpm3WVcffyGOpqksNZL1k1qO9QmIwAA+q8jroxjkUGLEn/GddfdExFfvofWfQiKZszUeQ==}
peerDependencies:
vue: '>= 3.4.0'
@@ -1307,15 +1313,15 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
[email protected].0:
resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==}
[email protected].1:
resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
typescript@6.0.3:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
@@ -1328,8 +1334,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected].2:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
[email protected].4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1418,25 +1424,14 @@ packages:
[email protected]:
resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==}
[email protected]:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
[email protected]:
resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
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 +1509,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 +1532,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]': {}
@@ -1559,14 +1554,11 @@ snapshots:
'@floating-ui/[email protected]': {}
'@floating-ui/vue@1.1.11([email protected](typescript@5.9.3))':
'@floating-ui/vue@2.0.1([email protected](typescript@6.0.3))':
dependencies:
'@floating-ui/dom': 1.8.0
'@floating-ui/utils': 0.2.12
vue-demi: 0.14.10([email protected](typescript@5.9.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
vue: 3.5.43(typescript@6.0.3)
'@fontsource-variable/[email protected]': {}
@@ -1582,10 +1574,10 @@ snapshots:
dependencies:
'@fortawesome/fontawesome-common-types': 7.3.1
'@fortawesome/[email protected](@fortawesome/[email protected])([email protected](typescript@5.9.3))':
'@fortawesome/[email protected](@fortawesome/[email protected])([email protected](typescript@6.0.3))':
dependencies:
'@fortawesome/fontawesome-svg-core': 7.3.1
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
'@humanfs/[email protected]':
dependencies:
@@ -1638,9 +1630,9 @@ snapshots:
'@keyv/[email protected]': {}
'@lucide/vue@1.49.0([email protected](typescript@5.9.3))':
'@lucide/vue@1.53.0([email protected](typescript@6.0.3))':
dependencies:
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
'@one-ini/[email protected]': {}
@@ -1758,19 +1750,19 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
'@tailwindcss/[email protected]([email protected].2(@types/[email protected])([email protected]))':
'@tailwindcss/[email protected]([email protected].4(@types/[email protected])([email protected]))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.3.2(@types/[email protected])([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
'@tanstack/[email protected]': {}
'@tanstack/[email protected]([email protected](typescript@5.9.3))':
'@tanstack/[email protected]([email protected](typescript@6.0.3))':
dependencies:
'@tanstack/virtual-core': 3.17.11
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
'@types/[email protected]':
dependencies:
@@ -1797,111 +1789,111 @@ snapshots:
dependencies:
'@types/node': 26.6.4
'@typescript-eslint/[email protected].0(@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3))([email protected]1.0([email protected]))(typescript@5.9.3)':
'@typescript-eslint/[email protected].1(@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3))([email protected]2.0([email protected]))(typescript@6.0.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/type-utils': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.71.0
eslint: 10.11.0([email protected])
'@typescript-eslint/parser': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/type-utils': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.1
eslint: 10.12.0([email protected])
ignore: 7.0.11
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3)':
'@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.71.1
debug: 4.4.3
eslint: 10.11.0([email protected])
typescript: 5.9.3
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0(typescript@5.9.3)':
'@typescript-eslint/[email protected].1(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3)
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3)
'@typescript-eslint/types': 8.71.1
debug: 4.4.3
typescript: 5.9.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/visitor-keys': 8.71.1
'@typescript-eslint/[email protected].0(typescript@5.9.3)':
'@typescript-eslint/[email protected].1(typescript@6.0.3)':
dependencies:
typescript: 5.9.3
typescript: 6.0.3
'@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3)':
'@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3)':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
debug: 4.4.3
eslint: 10.11.0([email protected])
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
eslint: 10.12.0([email protected])
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0': {}
'@typescript-eslint/[email protected].1': {}
'@typescript-eslint/[email protected].0(typescript@5.9.3)':
'@typescript-eslint/[email protected].1(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.71.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3)
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/visitor-keys': 8.71.0
'@typescript-eslint/project-service': 8.71.1(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3)
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/visitor-keys': 8.71.1
debug: 4.4.3
minimatch: 10.2.6
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3)':
'@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3)':
dependencies:
'@eslint-community/eslint-utils': 4.10.1([email protected]1.0([email protected]))
'@typescript-eslint/scope-manager': 8.71.0
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3)
eslint: 10.11.0([email protected])
typescript: 5.9.3
'@eslint-community/eslint-utils': 4.10.1([email protected]2.0([email protected]))
'@typescript-eslint/scope-manager': 8.71.1
'@typescript-eslint/types': 8.71.1
'@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3)
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.71.0
'@typescript-eslint/types': 8.71.1
eslint-visitor-keys: 5.0.1
'@vitejs/[email protected]([email protected].2(@types/[email protected])([email protected]))([email protected](typescript@5.9.3))':
'@vitejs/[email protected]([email protected].4(@types/[email protected])([email protected]))([email protected](typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.2(@types/[email protected])([email protected])
vue: 3.5.43(typescript@5.9.3)
vite: 8.3.4(@types/[email protected])([email protected])
vue: 3.5.43(typescript@6.0.3)
'@vitest/[email protected]([email protected].2(@types/[email protected])([email protected]))':
'@vitest/[email protected]([email protected].4(@types/[email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.2(@types/[email protected])([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
'@vitest/[email protected]': {}
@@ -1911,13 +1903,13 @@ snapshots:
'@volar/[email protected]': {}
'@volar/[email protected](typescript@5.9.3)':
'@volar/[email protected](typescript@6.0.3)':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.2.0
optionalDependencies:
typescript: 5.9.3
typescript: 6.0.3
'@vue/[email protected]':
dependencies:
@@ -1962,7 +1954,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
@@ -1996,40 +1988,40 @@ snapshots:
'@vue/[email protected]': {}
'@vue/[email protected](@vue/[email protected])(@vue/[email protected])([email protected](typescript@5.9.3))':
'@vue/[email protected](@vue/[email protected])(@vue/[email protected])([email protected](typescript@6.0.3))':
dependencies:
'@vue/compiler-dom': 3.5.43
js-beautify: 2.0.3
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
vue-component-type-helpers: 3.3.11
optionalDependencies:
'@vue/server-renderer': 3.5.43
'@vueuse/[email protected]([email protected](typescript@5.9.3))':
'@vueuse/[email protected]([email protected](typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.4.0
'@vueuse/shared': 14.4.0([email protected](typescript@5.9.3))
vue: 3.5.43(typescript@5.9.3)
'@vueuse/shared': 14.4.0([email protected](typescript@6.0.3))
vue: 3.5.43(typescript@6.0.3)
'@vueuse/[email protected]([email protected](typescript@5.9.3))':
'@vueuse/[email protected]([email protected](typescript@6.0.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 15.0.0
'@vueuse/shared': 15.0.0([email protected](typescript@5.9.3))
vue: 3.5.43(typescript@5.9.3)
'@vueuse/shared': 15.0.0([email protected](typescript@6.0.3))
vue: 3.5.43(typescript@6.0.3)
'@vueuse/[email protected]': {}
'@vueuse/[email protected]': {}
'@vueuse/[email protected]([email protected](typescript@5.9.3))':
'@vueuse/[email protected]([email protected](typescript@6.0.3))':
dependencies:
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
'@vueuse/[email protected]([email protected](typescript@5.9.3))':
'@vueuse/[email protected]([email protected](typescript@6.0.3))':
dependencies:
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
[email protected]: {}
@@ -2129,18 +2121,18 @@ snapshots:
[email protected]: {}
[email protected](@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3))([email protected]1.0([email protected]))([email protected]([email protected]1.0([email protected]))):
[email protected](@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3))([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]))(typescript@5.9.3)
'@typescript-eslint/parser': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
[email protected]:
dependencies:
@@ -2153,9 +2145,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
@@ -2260,7 +2252,7 @@ snapshots:
[email protected]: {}
[email protected].5:
[email protected].6:
dependencies:
'@types/node': 26.6.4
'@types/whatwg-mimetype': 3.0.2
@@ -2498,13 +2490,13 @@ snapshots:
[email protected]: {}
[email protected](@vue/[email protected])(typescript@5.9.3)([email protected](typescript@5.9.3)):
[email protected](@vue/[email protected])(typescript@6.0.3)([email protected](typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 8.2.1
nostics: 1.3.0
vue: 3.5.43(typescript@5.9.3)
vue: 3.5.43(typescript@6.0.3)
optionalDependencies:
typescript: 5.9.3
typescript: 6.0.3
[email protected]:
dependencies:
@@ -2517,6 +2509,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.2
[email protected]:
dependencies:
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.2
[email protected]: {}
[email protected]: {}
@@ -2529,21 +2527,18 @@ snapshots:
dependencies:
hookified: 2.2.0
[email protected]0.5([email protected](typescript@5.9.3)):
[email protected]1.0([email protected](typescript@6.0.3)):
dependencies:
'@floating-ui/dom': 1.8.0
'@floating-ui/vue': 1.1.11([email protected]([email protected]))
'@floating-ui/vue': 2.0.1([email protected]([email protected]))
'@internationalized/date': 3.12.4
'@internationalized/number': 3.6.8
'@tanstack/vue-virtual': 3.13.39([email protected](typescript@5.9.3))
'@vueuse/core': 14.4.0([email protected](typescript@5.9.3))
'@vueuse/shared': 14.4.0([email protected](typescript@5.9.3))
'@tanstack/vue-virtual': 3.13.39([email protected](typescript@6.0.3))
'@vueuse/core': 14.4.0([email protected](typescript@6.0.3))
'@vueuse/shared': 14.4.0([email protected](typescript@6.0.3))
aria-hidden: 1.2.6
defu: 6.1.7
ohash: 2.0.12
vue: 3.5.43(typescript@5.9.3)
transitivePeerDependencies:
- '@vue/composition-api'
vue: 3.5.43(typescript@6.0.3)
[email protected]:
dependencies:
@@ -2593,9 +2588,9 @@ snapshots:
fdir: 6.5.0([email protected])
picomatch: 4.0.7
[email protected](typescript@5.9.3):
[email protected](typescript@6.0.3):
dependencies:
typescript: 5.9.3
typescript: 6.0.3
[email protected]: {}
@@ -2603,18 +2598,18 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3):
[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/[email protected].0([email protected]1.0([email protected]))(typescript@5.9.3))([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/parser': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.71.0([email protected]1.0([email protected]))(typescript@5.9.3)
eslint: 10.11.0([email protected])
typescript: 5.9.3
'@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/[email protected].1([email protected]2.0([email protected]))(typescript@6.0.3))([email protected]2.0([email protected]))(typescript@6.0.3)
'@typescript-eslint/parser': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3)
'@typescript-eslint/utils': 8.71.1([email protected]2.0([email protected]))(typescript@6.0.3)
eslint: 10.12.0([email protected])
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
typescript@6.0.3: {}
[email protected]: {}
@@ -2624,11 +2619,11 @@ snapshots:
[email protected]: {}
[email protected].2(@types/[email protected])([email protected]):
[email protected].4(@types/[email protected])([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
postcss: 8.5.29
rolldown: 1.2.12
tinyglobby: 0.2.17
optionalDependencies:
@@ -2636,10 +2631,10 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
[email protected](@types/[email protected])([email protected].5)([email protected].2(@types/[email protected])([email protected])):
[email protected](@types/[email protected])([email protected].6)([email protected].4(@types/[email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3([email protected].2(@types/[email protected])([email protected]))
'@vitest/mocker': 5.0.3([email protected].4(@types/[email protected])([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -2650,11 +2645,11 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.2(@types/[email protected])([email protected])
vite: 8.3.4(@types/[email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.4
happy-dom: 20.14.5
happy-dom: 20.14.6
transitivePeerDependencies:
- msw
@@ -2662,14 +2657,10 @@ snapshots:
[email protected]: {}
vue-demi@0.14.10([email protected]([email protected])):
dependencies:
vue: 3.5.43([email protected])
[email protected]([email protected]([email protected])):
vue-eslint-parser@10.4.1([email protected]([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,13 +2669,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]1(typescript@5.9.3):
[email protected]2(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28(typescript@5.9.3)
'@vue/language-core': 3.3.11
typescript: 5.9.3
'@volar/typescript': 2.4.28(typescript@6.0.3)
'@vue/language-core': 3.3.12
typescript: 6.0.3
[email protected](typescript@5.9.3):
[email protected](typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.43
'@vue/compiler-sfc': 3.5.43
@@ -2692,7 +2683,7 @@ snapshots:
'@vue/server-renderer': 3.5.43
'@vue/shared': 3.5.43
optionalDependencies:
typescript: 5.9.3
typescript: 6.0.3
[email protected]: {}
+11
View File
@@ -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"
}
]
}
+2 -2
View File
@@ -116,7 +116,7 @@ onBeforeUnmount(() => {
/>
</div>
<div
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 xl:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
>
<AttentionList
ref="list"
@@ -133,7 +133,7 @@ onBeforeUnmount(() => {
@clear="store.clearFilters()"
@refresh="store.refreshRepo"
/>
<aside class="lg:sticky lg:top-6 lg:self-start">
<aside class="xl:sticky xl:top-6 xl:self-start">
<ProjectList
:repos="visibleRepos"
:total="state.repos.length"
+1 -1
View File
@@ -89,7 +89,7 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
<ul
v-else
ref="scroller"
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] xl:max-h-[calc(100vh-7rem)]"
>
<li
v-for="repo in visible"
+8 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faScrewdriverWrench, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
AlertDialog,
@@ -44,14 +44,18 @@ function press(a: ActionDef): void {
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
<template v-for="a in row.actions" :key="a.id">
<Button
variant="outline"
size="xs"
size="sm"
class="font-semibold shadow-sm"
:disabled="busy(a)"
:aria-busy="busy(a)"
:data-test="`action-${a.id}`"
@click="press(a)"
>
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" />
<FontAwesomeIcon
:icon="busy(a) ? faSpinner : faScrewdriverWrench"
:spin="busy(a)"
aria-hidden="true"
/>
{{ a.label }}
</Button>
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
+1 -1
View File
@@ -156,7 +156,7 @@ describe('AttentionRow', () => {
it('omits the age for an unknown timestamp', () => {
const w = mount(AttentionRow, {
props: {
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }),
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issue_stale' }),
nowMs,
critSeconds: CRIT_SECONDS,
},
+1 -1
View File
@@ -1,7 +1,7 @@
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
export function useColumns(minWidth = '(min-width: 90rem)'): Ref<number> {
const columns = ref(1)
let query: MediaQueryList | null = null
const update = () => {
+2 -2
View File
@@ -38,7 +38,7 @@ const fleet = () =>
}),
alert({
repo: 'b/two',
kind: 'issues_stale',
kind: 'issue_stale',
severity: 'warn',
subject: '4 stale issues',
url: '3',
@@ -62,7 +62,7 @@ describe('kindGroup', () => {
['workflow_stuck', 'workflow'],
['pr_waiting', 'pr'],
['pr_checks_failing', 'pr'],
['issues_stale', 'issue'],
['issue_stale', 'issue'],
['refresh_failed', 'data'],
['data_stale', 'data'],
['partial_data', 'data'],
+1 -1
View File
@@ -24,7 +24,7 @@ export const emptyFilters = (): Filters => ({
export function kindGroup(kind: string): KindGroup {
if (kind.startsWith('workflow_')) return 'workflow'
if (kind.startsWith('pr_')) return 'pr'
if (kind === 'issues_stale') return 'issue'
if (kind.startsWith('issue_')) return 'issue'
return 'data'
}
+3 -1
View File
@@ -14,7 +14,9 @@ export const KIND_ICON: Record<string, IconDefinition> = {
workflow_stuck: faGears,
pr_waiting: faCodePullRequest,
pr_checks_failing: faCodePullRequest,
issues_stale: faCircleDot,
pr_fresh: faCodePullRequest,
issue_stale: faCircleDot,
issue_fresh: faCircleDot,
refresh_failed: faPlugCircleXmark,
data_stale: faClockRotateLeft,
partial_data: faTriangleExclamation,
+1 -2
View File
@@ -13,8 +13,7 @@
"verbatimModuleSyntax": true,
"jsx": "preserve",
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}