Compare commits

..
11 Commits
Author SHA1 Message Date
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
lukaszraczylo 23bbbedf59 feat(ui): show alert action buttons and status chips
Alerts that match a configured action get a button labelled from the
config. A confirm dialog opens first when the action defines confirm text.
After a press the UI posts the alert, reloads the state through the
existing poll path and shows the reported status as a chip, with an anchor
when the status carries a link.

The alert dialog comes from reka-ui through the usual shadcn-vue wrappers.
2026-10-04 16:37:31 +01:00
lukaszraczylo 5824c4dd10 feat(web): add opt-in webhook alert actions
An optional actions block in the config adds buttons to alerts. POST
/api/actions/{id} checks that the repository and the current alert match,
then sends the alert as JSON to the configured webhook with an optional
bearer token. The request refuses cross-site callers like POST /refresh.

GET /api/state lists the actions and merges per-alert status rows from an
optional status_url, cached for 10 seconds. Without an actions block the
API output does not change.

The ghtracker_action_requests_total counter records each result.
2026-10-04 16:35:26 +01:00
github-actions[bot]andGitHub 32de5d6704 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:16:19 +00:00
lukaszraczylo 3ce2c50f5a ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:56 +01:00
40 changed files with 2653 additions and 1574 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Update dependencies
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
permissions:
actions: write
contents: write
issues: write
pull-requests: write
jobs:
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: release.yaml
+48
View File
@@ -70,6 +70,53 @@ Alerts are evaluated when you read them, so PR ages keep advancing between refre
The dashboard shows a refresh button beside each project when `manual_refresh` is on. The dashboard shows a refresh button beside each project when `manual_refresh` is on.
## Alert actions
An action adds a button to alerts. Pressing it sends the alert as JSON to a webhook that you run, for example to re-run a failed pipeline or to open a ticket. Without an `actions` block the feature is off: the state document, the endpoints and the UI do not change.
```yaml
actions:
- id: rerun # [a-z0-9-]+, appears in the payload and the endpoint
label: Re-run # button text
kinds: [workflow_failed] # alert kinds that show the button; omit for every kind
webhook:
url: https://example.internal/hooks/rerun
token_env: ACTION_TOKEN # name of an environment variable that holds a bearer token; optional
timeout: 10s
status_url: https://example.internal/hooks/rerun/status # optional
confirm: 'Run this action for the selected alert?' # optional; omit for no dialog
```
- `POST /api/actions/{id}` takes `{"repo": "owner/name", "url": "<alert url>"}`. It refuses cross-site requests like `POST /refresh`. The action must exist, the repository must be in the configured list, and a current alert with that repo and URL must have a kind listed in `kinds`. Otherwise the answer is 404.
- The server then POSTs `{"action": "<id>", "alert": {"repo", "kind", "severity", "subject", "detail", "url", "since"}, "requestedAt": "<RFC 3339>"}` to `webhook.url`. When `token_env` is set, the request carries `Authorization: Bearer <token>`. The server never logs the token and never follows redirects.
- A 2xx answer succeeds. If its body is JSON `{"state", "label", "link"}`, the UI shows it as the status of that alert. A non-2xx answer becomes an error message with the upstream body cut to 300 characters. An unreachable webhook gives 502.
- `GET /api/state` gains `actions` (`id`, `label`, `confirm`, `kinds`; no URLs and no tokens). When `status_url` is set, the server also fetches it with `GET` (same bearer token) and expects a JSON list of `{"repo", "url", "state", "label", "link"}` rows, newest first. It matches rows to alerts by repo and URL and adds `actionStatus` to each alert, keyed by action id. The result is cached for 10 seconds. If `status_url` is down, the state omits `actionStatus` and sets `actionsError`.
- 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 ## Metrics
All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
@@ -84,6 +131,7 @@ All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
| `release_info`, `release_published_timestamp_seconds` | repo, version, url | | `release_info`, `release_published_timestamp_seconds` | repo, version, url |
| `alerts` | severity | | `alerts` | severity |
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none | | `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none |
| `action_requests_total` (counter; result is `ok`, `rejected` or `error`, plus `auto_*` for auto actions; only with `actions`) | action, result |
Example alert rules: Example alert rules:
+19
View File
@@ -14,6 +14,25 @@ thresholds: # negative disables a check
issue_stale_after: 720h # no activity this long = warning (aggregated per repo) issue_stale_after: 720h # no activity this long = warning (aggregated per repo)
workflow_stuck_after: 6h # queued / in progress this long = warning workflow_stuck_after: 6h # queued / in progress this long = warning
# Optional buttons on alerts. Each one POSTs the alert as JSON to a webhook. Omit the block to turn the feature off.
# actions:
# - id: rerun # [a-z0-9-]+
# label: Re-run # button text
# kinds: [workflow_failed] # omit for every alert kind
# webhook:
# url: https://example.internal/hooks/rerun
# token_env: ACTION_TOKEN # env var holding a bearer token; optional
# 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 max_items: 100 # per repo, issues and PRs
concurrency: 2 concurrency: 2
+6 -6
View File
@@ -1,6 +1,6 @@
module github.com/lukaszraczylo/ghtracker module github.com/lukaszraczylo/ghtracker
go 1.25.0 go 1.26.0
require ( require (
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
@@ -12,9 +12,9 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+14 -16
View File
@@ -2,8 +2,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/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 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
@@ -12,26 +10,26 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= 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.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= 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/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= golang.org/x/sys v0.48.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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+27 -4
View File
@@ -65,6 +65,7 @@ type Collector struct {
log *slog.Logger log *slog.Logger
webBase string webBase string
kick chan struct{} kick chan struct{}
onRefresh []func(context.Context, View)
names []string names []string
th config.Thresholds th config.Thresholds
interval time.Duration interval time.Duration
@@ -100,19 +101,36 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
func (c *Collector) Run(ctx context.Context) { func (c *Collector) Run(ctx context.Context) {
t := time.NewTicker(c.interval) t := time.NewTicker(c.interval)
defer t.Stop() defer t.Stop()
c.Refresh(ctx) c.refreshAndNotify(ctx)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-t.C: case <-t.C:
c.Refresh(ctx) c.refreshAndNotify(ctx)
case <-c.kick: case <-c.kick:
c.Refresh(ctx) c.refreshAndNotify(ctx)
} }
} }
} }
// OnRefresh registers fn to run after every scan started by Run; call it before Run.
func (c *Collector) OnRefresh(fn func(context.Context, View)) { c.onRefresh = append(c.onRefresh, fn) }
func (c *Collector) refreshAndNotify(ctx context.Context) {
c.Refresh(ctx)
if 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. // RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued.
func (c *Collector) RequestRefresh() bool { func (c *Collector) RequestRefresh() bool {
if c.refreshing.Load() { if c.refreshing.Load() {
@@ -224,7 +242,12 @@ func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error)
c.mu.Lock() c.mu.Lock()
c.repos[canon] = r c.repos[canon] = r
c.mu.Unlock() 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) { func (c *Collector) configured(name string) (string, bool) {
+109 -13
View File
@@ -66,19 +66,19 @@ func TestEvaluate(t *testing.T) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}} r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
}, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn}, }, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
"pr fresh": {func(r *model.Repo) { "pr fresh": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-47 * time.Hour)}}
}, map[string]model.Severity{}, model.OK}, }, map[string]model.Severity{}, model.OK},
"pr waiting warn": {func(r *model.Repo) { "pr waiting warn": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-49 * time.Hour)}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-49 * time.Hour)}}
}, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn}, }, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn},
"pr waiting crit": {func(r *model.Repo) { "pr waiting crit": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-8 * 24 * time.Hour)}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-8 * 24 * time.Hour)}}
}, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit}, }, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
"draft pr ignored": {func(r *model.Repo) { "draft pr ignored": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}} r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}}
}, map[string]model.Severity{}, model.OK}, }, map[string]model.Severity{}, model.OK},
"pr checks failing": {func(r *model.Repo) { "pr checks failing": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn}, }, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
"issues stale aggregated": {func(r *model.Repo) { "issues stale aggregated": {func(r *model.Repo) {
old := t0.Add(-40 * 24 * time.Hour) old := t0.Add(-40 * 24 * time.Hour)
@@ -86,14 +86,14 @@ func TestEvaluate(t *testing.T) {
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn}, }, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
"archived repo skips work alerts": {func(r *model.Repo) { "archived repo skips work alerts": {func(r *model.Repo) {
r.Archived = true r.Archived = true
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-30 * 24 * time.Hour)}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-30 * 24 * time.Hour)}}
}, map[string]model.Severity{}, model.OK}, }, map[string]model.Severity{}, model.OK},
} }
for name, tt := range tests { for name, tt := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
r := fresh() r := fresh()
tt.mutate(&r) tt.mutate(&r)
alerts := Evaluate(&r, t0, th()) alerts := withoutPROpen(Evaluate(&r, t0, th()))
got := kinds(alerts) got := kinds(alerts)
if len(got) != len(tt.want) { if len(got) != len(tt.want) {
t.Fatalf("alerts = %+v, want kinds %v", alerts, 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) { func TestEvaluateDisabledThresholds(t *testing.T) {
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-100 * 24 * time.Hour)}}, r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-100 * 24 * time.Hour)}},
Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}} Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1} off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
if a := Evaluate(&r, t0, off); len(a) != 0 { if a := withoutPROpen(Evaluate(&r, t0, off)); len(a) != 0 {
t.Fatalf("disabled checks produced %+v", a) t.Fatalf("disabled checks produced %+v", a)
} }
} }
func TestAgeAdvancesWithoutRefresh(t *testing.T) { func TestAgeAdvancesWithoutRefresh(t *testing.T) {
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}} r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-40 * time.Hour)}}}
cfg := th() cfg := th()
cfg.DataStaleAfter = -1 cfg.DataStaleAfter = -1
if a := Evaluate(&r, t0, cfg); len(a) != 0 { if a := withoutPROpen(Evaluate(&r, t0, cfg)); len(a) != 0 {
t.Fatalf("unexpected %+v", a) 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) 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"} c := &model.Run{Status: "completed", Conclusion: "failure"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}} r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
case "o/warn": case "o/warn":
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-72 * time.Hour)}} r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-72 * time.Hour)}}
} }
return r, nil return r, nil
}} }}
@@ -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" { 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) 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) t.Fatalf("alerts: %+v", v.Alerts)
} }
} }
@@ -457,3 +469,87 @@ func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
t.Fatalf("%+v", c.View().Repos) t.Fatalf("%+v", c.View().Repos)
} }
} }
func TestEvaluatePROpen(t *testing.T) {
pr := func(mut func(*model.PullRequest)) model.PullRequest {
p := model.PullRequest{Number: 7, Title: "fix", URL: "https://github.com/o/r/pull/7", Author: "alice", HeadSHA: "abc123", CreatedAt: t0.Add(-5 * time.Hour)}
mut(&p)
return p
}
tests := map[string]struct {
pr model.PullRequest
archived bool
want bool
}{
"open": {pr(func(*model.PullRequest) {}), false, true},
"fork": {pr(func(p *model.PullRequest) { p.Fork = true }), false, 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):
}
}
+4
View File
@@ -20,6 +20,8 @@ const (
KindPRWaiting = "pr_waiting" KindPRWaiting = "pr_waiting"
KindPRChecks = "pr_checks_failing" KindPRChecks = "pr_checks_failing"
KindIssuesStale = "issues_stale" KindIssuesStale = "issues_stale"
// KindPROpen is informational plumbing for automatic actions; it has OK severity so it never moves health or counts.
KindPROpen = "pr_open"
) )
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health. // Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
@@ -62,6 +64,8 @@ func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert
} }
age := now.Sub(pr.CreatedAt) age := now.Sub(pr.CreatedAt)
subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title) subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title)
alerts = append(alerts, model.Alert{Severity: model.OK, Repo: r.FullName, Kind: KindPROpen, Subject: subject,
Detail: "open for " + Age(age), URL: pr.URL, Ref: pr.HeadSHA, Author: pr.Author, Since: pr.CreatedAt})
switch { switch {
case th.PRCritAfter > 0 && age >= th.PRCritAfter: case th.PRCritAfter > 0 && age >= th.PRCritAfter:
add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt) add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
+101
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
@@ -28,13 +29,66 @@ const (
MinRefresh = time.Minute MinRefresh = time.Minute
dataStaleGrace = 30 * time.Minute dataStaleGrace = 30 * time.Minute
defaultDataStaleFactor = 2 defaultDataStaleFactor = 2
DefaultActionTimeout = 10 * time.Second
) )
var actionIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
// Action is an operator-defined button on alerts. Pressing it POSTs the alert to a webhook.
type Action struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Confirm string `yaml:"confirm"`
StatusURL string `yaml:"status_url"`
// Token comes from the environment variable named by Webhook.TokenEnv, never from the file.
Token string `yaml:"-"`
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 {
URL string `yaml:"url"`
TokenEnv string `yaml:"token_env"`
Timeout time.Duration `yaml:"timeout"`
}
// AppliesTo reports whether the action is offered for an alert kind.
func (a Action) AppliesTo(kind string) bool {
if len(a.Kinds) == 0 {
return true
}
for _, k := range a.Kinds {
if k == kind {
return true
}
}
return false
}
type Config struct { type Config struct {
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on. // ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
ManualRefresh *bool `yaml:"manual_refresh"` ManualRefresh *bool `yaml:"manual_refresh"`
Listen string `yaml:"listen"` Listen string `yaml:"listen"`
Repos []string `yaml:"repos"` Repos []string `yaml:"repos"`
Actions []Action `yaml:"actions"`
GitHub GitHub `yaml:"github"` GitHub GitHub `yaml:"github"`
Thresholds Thresholds `yaml:"thresholds"` Thresholds Thresholds `yaml:"thresholds"`
RefreshInterval time.Duration `yaml:"refresh_interval"` RefreshInterval time.Duration `yaml:"refresh_interval"`
@@ -127,6 +181,10 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
c.GitHub.PrivateKeyPEM = string(pem) c.GitHub.PrivateKeyPEM = string(pem)
} }
if err := finalizeActions(c.Actions, getenv); err != nil {
return err
}
repos, err := normalizeRepos(c.Repos) repos, err := normalizeRepos(c.Repos)
if err != nil { if err != nil {
return err return err
@@ -135,6 +193,49 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
return nil return nil
} }
func finalizeActions(actions []Action, getenv func(string) string) error {
seen := make(map[string]bool, len(actions))
for i := range actions {
a := &actions[i]
if !actionIDPattern.MatchString(a.ID) {
return fmt.Errorf("actions[%d].id %q must match [a-z0-9-]+", i, a.ID)
}
if seen[a.ID] {
return fmt.Errorf("actions[%d].id %q is used twice", i, a.ID)
}
seen[a.ID] = true
if a.Label == "" {
a.Label = a.ID
}
if !isHTTPURL(a.Webhook.URL) {
return fmt.Errorf("actions[%s].webhook.url %q must be an http or https address", a.ID, a.Webhook.URL)
}
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)
}
if a.Webhook.Timeout == 0 {
a.Webhook.Timeout = DefaultActionTimeout
}
if env := a.Webhook.TokenEnv; env != "" {
if a.Token = getenv(env); a.Token == "" {
return fmt.Errorf("actions[%s].webhook.token_env: $%s is empty", a.ID, env)
}
}
}
return nil
}
func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func setDefault(d *time.Duration, def time.Duration) { func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 { if *d == 0 {
*d = def *d = def
+99
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -145,3 +146,101 @@ func TestWebBase(t *testing.T) {
} }
} }
} }
func TestActionsDefaultOff(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if len(c.Actions) != 0 {
t.Fatalf("actions must default to none: %+v", c.Actions)
}
}
func TestActionsFinalize(t *testing.T) {
good := func() Action {
return Action{ID: "rerun", Webhook: Webhook{URL: "https://hooks.example.test/rerun", TokenEnv: "T"}}
}
tests := map[string]struct {
mutate func(*Action)
extra []Action
wantErr bool
}{
"ok": {mutate: func(*Action) {}},
"no token env": {mutate: func(a *Action) { a.Webhook.TokenEnv = "" }},
"bad id upper": {mutate: func(a *Action) { a.ID = "Re_run" }, wantErr: true},
"empty id": {mutate: func(a *Action) { a.ID = "" }, wantErr: true},
"duplicate id": {mutate: func(*Action) {}, extra: []Action{good()}, wantErr: true},
"bad webhook": {mutate: func(a *Action) { a.Webhook.URL = "ftp://x" }, wantErr: true},
"no webhook": {mutate: func(a *Action) { a.Webhook.URL = "" }, wantErr: true},
"bad status url": {mutate: func(a *Action) { a.StatusURL = "nope" }, wantErr: true},
"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) {
a := good()
tc.mutate(&a)
c := valid()
c.Actions = append([]Action{a}, tc.extra...)
err := c.finalize(env(map[string]string{"T": "s3cret"}), keyFile)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if err != nil && strings.Contains(err.Error(), "s3cret") {
t.Fatal("error leaks token")
}
if err == nil {
got := c.Actions[0]
if got.Label != "rerun" || got.Webhook.Timeout != DefaultActionTimeout {
t.Fatalf("defaults wrong: %+v", got)
}
if a.Webhook.TokenEnv == "T" && got.Token != "s3cret" {
t.Fatalf("token not read: %+v", got)
}
}
})
}
}
func TestActionAppliesTo(t *testing.T) {
tests := []struct {
kind string
kinds []string
want bool
}{
{kinds: nil, kind: "anything", want: true},
{kinds: []string{"workflow_failed"}, kind: "workflow_failed", want: true},
{kinds: []string{"workflow_failed"}, kind: "pr_waiting", want: false},
{kinds: []string{"a", "b"}, kind: "b", want: true},
}
for _, tc := range tests {
if got := (Action{Kinds: tc.kinds}).AppliesTo(tc.kind); got != tc.want {
t.Errorf("%v.AppliesTo(%q) = %v", tc.kinds, tc.kind, got)
}
}
}
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" "fmt"
"net/url" "net/url"
"slices" "slices"
"strings"
"time" "time"
"github.com/lukaszraczylo/ghtracker/internal/model" "github.com/lukaszraczylo/ghtracker/internal/model"
) )
const ( const (
maxFailingNames = 5 maxFailingNames = 5
runsPerWorkflow = 5 runsPerWorkflow = 5
maxWorkflows = 100 // freshRunsWindowDays bounds the created filter of the confirming run query.
eventPullRequest = "pull_request" freshRunsWindowDays = 30
eventPullReqTgt = "pull_request_target" maxWorkflows = 100
statusStateFail = "failure" eventPullRequest = "pull_request"
statusStateError = "error" eventPullReqTgt = "pull_request_target"
statusStatePend = "pending" statusStateFail = "failure"
statusStateError = "error"
statusStatePend = "pending"
) )
// backgroundEvents are triggers that run without a person pushing code. // 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"` HTMLURL string `json:"html_url"`
User userRef `json:"user"` User userRef `json:"user"`
Head struct { Head struct {
SHA string `json:"sha"` Repo *repoRef `json:"repo"`
SHA string `json:"sha"`
} `json:"head"` } `json:"head"`
Base struct {
Repo *repoRef `json:"repo"`
} `json:"base"`
Labels []labelRef `json:"labels"` Labels []labelRef `json:"labels"`
Number int `json:"number"` Number int `json:"number"`
Draft bool `json:"draft"` Draft bool `json:"draft"`
@@ -141,13 +148,22 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest,
for _, p := range items { for _, p := range items {
out = append(out, model.PullRequest{ out = append(out, model.PullRequest{
Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft, Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft,
Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, Fork: isFork(p.Head.Repo, p.Base.Repo), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
Checks: model.CheckSummary{State: model.ChecksUnknown}, Checks: model.CheckSummary{State: model.ChecksUnknown},
}) })
} }
return out, err return out, err
} }
type repoRef struct {
FullName string `json:"full_name"`
}
// isFork reports a head repo that differs from the base repo; a deleted head repo counts as a fork.
func isFork(head, base *repoRef) bool {
return head == nil || base == nil || !strings.EqualFold(head.FullName, base.FullName)
}
// attachChecks fills each PR's check summary; it reports the first failure but still tries every PR. // attachChecks fills each PR's check summary; it reports the first failure but still tries every PR.
func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error { func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error {
var first error var first error
@@ -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) path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
get := c.getJSON get := c.getJSON
if fresh { if fresh {
// GitHub can keep answering one exact query from an old cached result, for weeks, whatever // GitHub can keep answering a branch query from an old cached result, for weeks, whatever
// the connection. A different query string skips that entry and reads current runs. // 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("page", "1")
q.Set("created", ">="+c.now().UTC().AddDate(0, 0, -freshRunsWindowDays).Format(time.DateOnly))
get = c.getJSONFresh get = c.getJSONFresh
} }
if err := get(ctx, full, path, q, &out); err != nil { 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 // 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 { func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
var calls atomic.Int64 var calls atomic.Int64
f.routes[path] = func(w http.ResponseWriter, r *http.Request) { f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
calls.Add(1) calls.Add(1)
if r.URL.Query().Get("page") == "" { if r.URL.Query().Get("created") == "" {
_, _ = w.Write([]byte(`{"workflow_runs":[ _, _ = 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"}]}`)) {"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
return return
@@ -553,3 +553,29 @@ func TestFreshClientDisablesConnectionReuse(t *testing.T) {
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestOpenPRsHeadSHAAndFork(t *testing.T) {
tests := map[string]struct {
pr string
wantFork bool
}{
"same repo": {`"head":{"sha":"abc","repo":{"full_name":"o/r"}},"base":{"repo":{"full_name":"o/r"}}`, false},
"same repo casing": {`"head":{"sha":"abc","repo":{"full_name":"O/R"}},"base":{"repo":{"full_name":"o/r"}}`, false},
"other repo": {`"head":{"sha":"abc","repo":{"full_name":"x/r"}},"base":{"repo":{"full_name":"o/r"}}`, true},
"deleted head": {`"head":{"sha":"abc","repo":null},"base":{"repo":{"full_name":"o/r"}}`, true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
f := newFake(t)
c := f.client(t, 50)
f.json("/repos/o/r/pulls", `[{"number":2,"title":"t","html_url":"u2","user":{"login":"b"},`+tc.pr+`}]`)
prs, err := c.openPRs(context.Background(), "o/r")
if err != nil || len(prs) != 1 {
t.Fatalf("prs = %+v, err = %v", prs, err)
}
if prs[0].HeadSHA != "abc" || prs[0].Fork != tc.wantFork {
t.Fatalf("HeadSHA = %q, Fork = %v, want abc, %v", prs[0].HeadSHA, prs[0].Fork, tc.wantFork)
}
})
}
}
+12 -6
View File
@@ -90,6 +90,8 @@ type PullRequest struct {
Checks CheckSummary Checks CheckSummary
Number int Number int
Draft bool Draft bool
// Fork is true when the head repository differs from the base or was deleted.
Fork bool
} }
type CheckSummary struct { type CheckSummary struct {
@@ -133,12 +135,16 @@ func (r Run) Failed() bool {
} }
type Alert struct { type Alert struct {
Since time.Time Since time.Time
Repo string Repo string
Kind string Kind string
Subject string Subject string
Detail string Detail string
URL string URL string
// Ref is the head commit of a pr_open alert; empty for other kinds.
Ref string
// Author is the pull request author of a pr_open alert; it is not part of the JSON API.
Author string
Severity Severity Severity Severity
} }
+357
View File
@@ -0,0 +1,357 @@
package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
statusCacheTTL = 10 * time.Second
maxActionBody = 4 << 10
maxUpstreamBody = 1 << 20
maxErrorChars = 300
actionsUnavail = "action status unavailable"
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"
)
// apiAction is the public description of an action; it never carries URLs or tokens.
type apiAction struct {
ID string `json:"id"`
Label string `json:"label"`
Confirm string `json:"confirm,omitempty"`
Kinds []string `json:"kinds"`
}
// apiActionStatus is what the UI shows next to an alert after an action ran.
type apiActionStatus struct {
State string `json:"state"`
Label string `json:"label,omitempty"`
Link string `json:"link,omitempty"`
}
type statusRow struct {
Repo string `json:"repo"`
URL string `json:"url"`
apiActionStatus
}
type alertRef struct{ repo, url string }
// actionRuntime holds one action and the cache of its status rows.
type actionRuntime struct {
fetched time.Time
rows map[alertRef]apiActionStatus
err error
cfg config.Action
mu sync.Mutex
}
// Actions runs the configured webhook actions and merges their status into the state.
type Actions struct {
now func() time.Time
hc *http.Client
log *slog.Logger
counter *prometheus.CounterVec
byID map[string]*actionRuntime
list []*actionRuntime
}
// NewActions builds the runner and registers ghtracker_action_requests_total on reg when reg is not nil.
func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registerer) *Actions {
a := &Actions{
now: time.Now, log: log, byID: make(map[string]*actionRuntime, len(cfgs)),
// Redirects would carry the bearer token to another host, so none are followed.
hc: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
counter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "ghtracker", Name: actionMetricName,
Help: "Action requests sent to webhooks, by action and result.",
}, []string{"action", "result"}),
}
for _, c := range cfgs {
rt := &actionRuntime{cfg: c}
a.list = append(a.list, rt)
a.byID[c.ID] = rt
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)
}
return a
}
// WithActions enables POST /api/actions/{id} and the action data in the state document.
func (s *Server) WithActions(a *Actions) *Server {
if a != nil && len(a.list) > 0 {
s.actions = a
}
return s
}
// decorate adds the action list and per-alert status to a state document.
func (s *Server) decorate(ctx context.Context, st *apiState) {
if s.actions == nil {
return
}
for _, rt := range s.actions.list {
kinds := rt.cfg.Kinds
if kinds == nil {
kinds = []string{}
}
st.Actions = append(st.Actions, apiAction{ID: rt.cfg.ID, Label: rt.cfg.Label, Confirm: rt.cfg.Confirm, Kinds: kinds})
if rt.cfg.StatusURL == "" {
continue
}
rows, err := s.actions.statusRows(ctx, rt)
if err != nil {
st.ActionsError = actionsUnavail
continue
}
for i := range st.Alerts {
al := &st.Alerts[i]
if !rt.cfg.AppliesTo(al.Kind) {
continue
}
if row, ok := rows[alertRef{strings.ToLower(al.Repo), al.URL}]; ok {
if al.ActionStatus == nil {
al.ActionStatus = map[string]apiActionStatus{}
}
al.ActionStatus[rt.cfg.ID] = row
}
}
}
}
// statusRows returns the cached rows of an action's status_url; failures are cached for the TTL too.
func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertRef]apiActionStatus, error) {
rt.mu.Lock()
defer rt.mu.Unlock()
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
return rt.rows, rt.err
}
rows, err := a.fetchStatus(ctx, rt.cfg)
if err != nil {
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
}
rt.rows, rt.err, rt.fetched = rows, err, a.now()
return rows, err
}
func (rt *actionRuntime) invalidate() {
rt.mu.Lock()
rt.fetched = time.Time{}
rt.mu.Unlock()
}
func (a *Actions) fetchStatus(ctx context.Context, c config.Action) (map[alertRef]apiActionStatus, error) {
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.StatusURL, nil)
if err != nil {
return nil, errors.New("invalid status url")
}
req.Header.Set("Accept", "application/json")
setBearer(req, c.Token)
resp, err := a.hc.Do(req)
if err != nil {
return nil, errors.New("request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var list []statusRow
if err := json.NewDecoder(io.LimitReader(resp.Body, maxUpstreamBody)).Decode(&list); err != nil {
return nil, errors.New("invalid reply")
}
out := make(map[alertRef]apiActionStatus, len(list))
for _, r := range list { // the first row per alert wins, so put the newest first
k := alertRef{strings.ToLower(r.Repo), r.URL}
if _, seen := out[k]; !seen {
out[k] = cleanStatus(r.apiActionStatus)
}
}
return out, nil
}
// cleanStatus drops a link that is not http(s), so the UI never renders a script URL as an anchor.
func cleanStatus(st apiActionStatus) apiActionStatus {
if u, err := url.Parse(st.Link); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
st.Link = ""
}
return st
}
func setBearer(req *http.Request, token string) {
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
type actionRequest struct {
Repo string `json:"repo"`
URL string `json:"url"`
}
type webhookAlert struct {
Since time.Time `json:"since"`
Repo string `json:"repo"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Detail string `json:"detail"`
URL string `json:"url"`
Ref string `json:"ref,omitempty"`
}
type webhookPayload struct {
RequestedAt time.Time `json:"requestedAt"`
Action string `json:"action"`
Alert webhookAlert `json:"alert"`
}
// runAction validates the request against the current state and forwards it to the action's webhook.
func (s *Server) runAction(w http.ResponseWriter, r *http.Request) {
if crossSite(r) {
http.Error(w, "cross-site action refused", http.StatusForbidden)
return
}
rt, ok := s.actions.byID[r.PathValue("id")]
if !ok {
writeJSONError(w, http.StatusNotFound, "unknown action")
return
}
var req actionRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionBody)).Decode(&req); err != nil || req.Repo == "" || req.URL == "" {
writeJSONError(w, http.StatusBadRequest, `body must be {"repo":"owner/name","url":"..."}`)
return
}
alert, code, msg := s.findAlert(rt.cfg, req)
if code != 0 {
writeJSONError(w, code, msg)
return
}
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)
writeJSONError(w, http.StatusBadGateway, "action webhook unreachable")
return
}
s.log.Info("action webhook", "action", rt.cfg.ID, "repo", alert.Repo, "status", status)
if status/100 != 2 {
result, out := resultFailed, http.StatusBadGateway
switch status / 100 {
case 4:
result, out = resultRejected, status
case 5:
out = status
}
s.actions.count(rt.cfg.ID, result)
writeJSONError(w, out, fmt.Sprintf("action webhook answered HTTP %d: %s", status, truncate(string(body), maxErrorChars)))
return
}
s.actions.count(rt.cfg.ID, resultOK)
rt.invalidate()
var st apiActionStatus
if json.Unmarshal(body, &st) != nil {
st = apiActionStatus{}
}
w.Header().Set(contentTypeHeader, "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = 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()
tracked := false
for _, repo := range v.Repos {
if strings.EqualFold(repo.FullName, req.Repo) {
tracked = true
break
}
}
if !tracked {
return model.Alert{}, http.StatusNotFound, "unknown repository"
}
for _, a := range v.Alerts {
if strings.EqualFold(a.Repo, req.Repo) && a.URL == req.URL && c.AppliesTo(a.Kind) {
return a, 0, ""
}
}
return model.Alert{}, http.StatusNotFound, "no such alert"
}
func (a *Actions) post(ctx context.Context, c config.Action, p webhookPayload) (int, []byte, error) {
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
defer cancel()
payload, err := json.Marshal(p)
if err != nil {
return 0, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Webhook.URL, bytes.NewReader(payload))
if err != nil {
return 0, nil, errors.New("invalid webhook url")
}
req.Header.Set(contentTypeHeader, "application/json")
setBearer(req, c.Token)
resp, err := a.hc.Do(req)
if err != nil {
return 0, nil, errors.New("request failed")
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody))
if err != nil {
return 0, nil, errors.New("read reply failed")
}
return resp.StatusCode, body, nil
}
func (a *Actions) count(action, result string) { a.counter.WithLabelValues(action, result).Inc() }
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return string(r[:n]) + "..."
}
return s
}
+409
View File
@@ -0,0 +1,409 @@
package web
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/lukaszraczylo/ghtracker/internal/config"
)
const (
testToken = "tok-SECRET-123"
alertURL = "https://github.com/o/r/actions/runs/1"
prURL = "https://github.com/o/r/pull/5"
)
type fakeHook struct {
srv *httptest.Server
postBody string
statBody string
lastAuth string
lastBody string
lastMethod string
postCode int
statCode int
mu sync.Mutex
postCalls atomic.Int32
statCalls atomic.Int32
}
func newFakeHook(t *testing.T) *fakeHook {
t.Helper()
f := &fakeHook{postCode: 200, postBody: `{"state":"queued","label":"Queued","link":"https://ci.example.test/run/9"}`, statCode: 200, statBody: `[]`}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.lastAuth, f.lastBody, f.lastMethod = r.Header.Get("Authorization"), string(b), r.Method
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/status" {
f.statCalls.Add(1)
w.WriteHeader(f.statCode)
_, _ = w.Write([]byte(f.statBody))
return
}
f.postCalls.Add(1)
w.WriteHeader(f.postCode)
_, _ = w.Write([]byte(f.postBody))
}))
t.Cleanup(f.srv.Close)
return f
}
type actionHarness struct {
srv *Server
actions *Actions
logs *bytes.Buffer
clock time.Time
}
func newActionServer(t *testing.T, mutate func(*config.Action), base string) *actionHarness {
t.Helper()
c := config.Action{ID: "rerun", Label: "Re-run", Kinds: []string{"workflow_failed"}, Confirm: "Run it?",
StatusURL: base + "/status", Token: testToken,
Webhook: config.Webhook{URL: base + "/hook", TokenEnv: "T", Timeout: 2 * time.Second}}
if mutate != nil {
mutate(&c)
}
h := &actionHarness{logs: &bytes.Buffer{}, clock: now}
log := slog.New(slog.NewTextHandler(h.logs, nil))
h.actions = NewActions([]config.Action{c}, log, prometheus.NewRegistry())
h.actions.now = func() time.Time { return h.clock }
h.srv = New(&fakeSource{v: testView()}, http.NotFoundHandler(), testUI(), true, log).WithActions(h.actions)
h.srv.now = func() time.Time { return now }
return h
}
func (h *actionHarness) post(path, body string, hdr map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.srv.Handler().ServeHTTP(rec, req)
return rec
}
func (h *actionHarness) state(t *testing.T) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(do(h.srv.Handler(), "GET", "/api/state").Body.Bytes(), &m); err != nil {
t.Fatal(err)
}
return m
}
func reqBody(repo, url string) string {
b, _ := json.Marshal(map[string]string{"repo": repo, "url": url})
return string(b)
}
func (h *actionHarness) counter(result string) float64 {
return testutil.ToFloat64(h.actions.counter.WithLabelValues("rerun", result))
}
func TestActionRequest(t *testing.T) {
tests := []struct {
hdr map[string]string
mutate func(*config.Action)
name string
path string
body string
postBody string
wantResult string
wantBody string
postCode int
wantCode int
wantPosts int32
}{
{name: "ok passes status through", wantCode: 200, wantPosts: 1, wantResult: "ok",
wantBody: `"state":"queued"`},
{name: "ok with empty body", postBody: "not json", wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `"state":""`},
{name: "repo case-insensitive", body: reqBody("O/R", alertURL), wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "javascript link dropped", postBody: `{"state":"x","link":"javascript:alert(1)"}`,
wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `{"state":"x"}`},
{name: "no token configured", mutate: func(a *config.Action) { a.Token = "" }, wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "upstream 4xx", postCode: 429, postBody: `{"error":"busy"}`, wantCode: 429, wantPosts: 1, wantResult: "rejected", wantBody: "HTTP 429"},
{name: "upstream 5xx", postCode: 500, postBody: "boom", wantCode: 500, wantPosts: 1, wantResult: "error", wantBody: "boom"},
{name: "upstream redirect not followed", postCode: 302, postBody: "moved", wantCode: 502, wantPosts: 1, wantResult: "error"},
{name: "upstream body truncated", postCode: 500, postBody: strings.Repeat("x", 1000), wantCode: 500, wantPosts: 1, wantResult: "error",
wantBody: strings.Repeat("x", 300) + "..."},
{name: "cross-site refused", hdr: map[string]string{"Sec-Fetch-Site": "cross-site"}, wantCode: 403},
{name: "same-site refused", hdr: map[string]string{"Sec-Fetch-Site": "same-site"}, wantCode: 403},
{name: "same-origin allowed", hdr: map[string]string{"Sec-Fetch-Site": "same-origin"}, wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "unknown action", path: "/api/actions/nope", wantCode: 404, wantBody: "unknown action"},
{name: "unknown repo", body: reqBody("x/y", alertURL), wantCode: 404, wantBody: "unknown repository"},
{name: "unknown alert", body: reqBody("o/r", alertURL+"9"), wantCode: 404, wantBody: "no such alert"},
{name: "kind not in kinds", body: reqBody("o/r", prURL), wantCode: 404, wantBody: "no such alert"},
{name: "empty kinds matches every kind", body: reqBody("o/r", prURL), mutate: func(a *config.Action) { a.Kinds = nil },
wantCode: 200, wantPosts: 1, wantResult: "ok"},
{name: "malformed body", body: `{`, wantCode: 400},
{name: "missing url", body: `{"repo":"o/r"}`, wantCode: 400},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fh := newFakeHook(t)
if tc.postCode != 0 {
fh.postCode = tc.postCode
}
if tc.postBody != "" {
fh.postBody = tc.postBody
}
h := newActionServer(t, tc.mutate, fh.srv.URL)
path, body := tc.path, tc.body
if path == "" {
path = "/api/actions/rerun"
}
if body == "" {
body = reqBody("o/r", alertURL)
}
rec := h.post(path, body, tc.hdr)
if rec.Code != tc.wantCode {
t.Fatalf("code = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body)
}
if fh.postCalls.Load() != tc.wantPosts {
t.Fatalf("webhook posts = %d, want %d", fh.postCalls.Load(), tc.wantPosts)
}
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
t.Fatalf("body %q lacks %q", rec.Body, tc.wantBody)
}
if tc.wantResult != "" && h.counter(tc.wantResult) != 1 {
t.Fatalf("counter %s = %v, want 1", tc.wantResult, h.counter(tc.wantResult))
}
if tc.wantPosts > 0 {
wantAuth := "Bearer " + testToken
if tc.mutate != nil && tc.name == "no token configured" {
wantAuth = ""
}
if fh.lastAuth != wantAuth {
t.Fatalf("auth = %q, want %q", fh.lastAuth, wantAuth)
}
}
if strings.Contains(h.logs.String(), testToken) || strings.Contains(rec.Body.String(), testToken) {
t.Fatal("token leaked into logs or response")
}
})
}
}
func TestActionWebhookPayload(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != 200 {
t.Fatal(rec.Body)
}
var got struct {
RequestedAt time.Time
Alert map[string]string
Action string
}
if err := json.Unmarshal([]byte(fh.lastBody), &got); err != nil {
t.Fatal(err)
}
if fh.lastMethod != "POST" || got.Action != "rerun" || !got.RequestedAt.Equal(now) {
t.Fatalf("payload: %+v (%s)", got, fh.lastBody)
}
want := map[string]string{"repo": "o/r", "kind": "workflow_failed", "severity": "crit", "subject": "CI", "url": alertURL}
for k, v := range want {
if got.Alert[k] != v {
t.Errorf("alert.%s = %q, want %q", k, got.Alert[k], v)
}
}
if got.Alert["detail"] == "" || got.Alert["since"] == "" {
t.Errorf("alert lacks detail or since: %v", got.Alert)
}
}
func TestActionWebhookDown(t *testing.T) {
fh := newFakeHook(t)
base := fh.srv.URL
fh.srv.Close()
h := newActionServer(t, nil, base)
rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
if rec.Code != http.StatusBadGateway || h.counter("error") != 1 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
if strings.Contains(h.logs.String(), testToken) {
t.Fatal("token leaked into logs")
}
}
func TestActionRouteAbsentWithoutActions(t *testing.T) {
h := newServer(&fakeSource{v: testView()}, testUI(), true).Handler()
if rec := do(h, "POST", "/api/actions/rerun"); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("code = %d", rec.Code)
}
}
func TestStateUnchangedWithoutActions(t *testing.T) {
body := do(newServer(&fakeSource{v: testView()}, testUI(), true).Handler(), "GET", "/api/state").Body.String()
for _, key := range []string{`"actions"`, `"actionStatus"`, `"actionsError"`} {
if strings.Contains(body, key) {
t.Fatalf("state gained %s without actions: %s", key, body)
}
}
}
func alertField(m map[string]any, kind, field string) any {
for _, a := range m["alerts"].([]any) {
if al := a.(map[string]any); al["kind"] == kind {
return al[field]
}
}
return nil
}
func TestStateMergesActionStatus(t *testing.T) {
tests := []struct {
wantState any
wantLink any
mutate func(*config.Action)
name string
rows string
}{
{name: "match by repo and url", rows: `[{"repo":"O/R","url":"` + alertURL + `","state":"running","label":"Running","link":"https://ci.example.test/1"}]`,
wantState: "running", wantLink: "https://ci.example.test/1"},
{name: "first row wins", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"new"},{"repo":"o/r","url":"` + alertURL + `","state":"old"}]`,
wantState: "new"},
{name: "other url ignored", rows: `[{"repo":"o/r","url":"` + alertURL + `x","state":"running"}]`},
{name: "other repo ignored", rows: `[{"repo":"o/other","url":"` + alertURL + `","state":"running"}]`},
{name: "unsafe link dropped", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"done","link":"javascript:x"}]`, wantState: "done"},
{name: "kind filter respected", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`,
mutate: func(a *config.Action) { a.Kinds = []string{"pr_waiting"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fh := newFakeHook(t)
fh.statBody = tc.rows
m := newActionServer(t, tc.mutate, fh.srv.URL).state(t)
if _, ok := m["actionsError"]; ok {
t.Fatalf("unexpected actionsError: %v", m["actionsError"])
}
byAction, _ := alertField(m, "workflow_failed", "actionStatus").(map[string]any)
st, _ := byAction["rerun"].(map[string]any)
if tc.wantState == nil {
if st != nil {
t.Fatalf("status present: %v", st)
}
return
}
if st["state"] != tc.wantState || st["link"] != tc.wantLink {
t.Fatalf("status = %v, want state %v link %v", st, tc.wantState, tc.wantLink)
}
})
}
}
func TestStateListsActionsWithoutSecrets(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
rec := do(h.srv.Handler(), "GET", "/api/state")
raw := rec.Body.String()
for _, secret := range []string{testToken, fh.srv.URL, "/hook", "/status"} {
if strings.Contains(raw, secret) {
t.Fatalf("state leaks %q", secret)
}
}
var m struct {
Actions []map[string]any
}
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil || len(m.Actions) != 1 {
t.Fatalf("%v %s", err, raw)
}
a := m.Actions[0]
if a["id"] != "rerun" || a["label"] != "Re-run" || a["confirm"] != "Run it?" || len(a["kinds"].([]any)) != 1 {
t.Fatalf("action = %v", a)
}
}
func TestStateStatusDown(t *testing.T) {
tests := map[string]func(*fakeHook, *string){
"server error": func(f *fakeHook, _ *string) { f.statCode = 500 },
"invalid json": func(f *fakeHook, _ *string) { f.statBody = "nope" },
"unreachable": func(f *fakeHook, base *string) { f.srv.Close(); *base = f.srv.URL },
}
for name, breakIt := range tests {
t.Run(name, func(t *testing.T) {
fh := newFakeHook(t)
base := fh.srv.URL
breakIt(fh, &base)
h := newActionServer(t, nil, base)
m := h.state(t)
if m["actionsError"] != actionsUnavail {
t.Fatalf("actionsError = %v", m["actionsError"])
}
if alertField(m, "workflow_failed", "actionStatus") != nil {
t.Fatal("actionStatus present while status is down")
}
if len(m["actions"].([]any)) != 1 {
t.Fatal("actions must still be listed so the buttons work")
}
if strings.Contains(h.logs.String(), testToken) {
t.Fatal("token leaked into logs")
}
})
}
}
func TestStateWithoutStatusURL(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, func(a *config.Action) { a.StatusURL = "" }, fh.srv.URL)
m := h.state(t)
if _, ok := m["actionsError"]; ok || fh.statCalls.Load() != 0 || len(m["actions"].([]any)) != 1 {
t.Fatalf("state: %v, status calls %d", m, fh.statCalls.Load())
}
}
func TestStatusCachedAndInvalidatedByRequest(t *testing.T) {
fh := newFakeHook(t)
h := newActionServer(t, nil, fh.srv.URL)
h.state(t)
h.state(t)
if fh.statCalls.Load() != 1 {
t.Fatalf("status calls = %d, want 1 inside the TTL", fh.statCalls.Load())
}
h.clock = h.clock.Add(statusCacheTTL + time.Second)
h.state(t)
if fh.statCalls.Load() != 2 {
t.Fatalf("status calls = %d, want 2 after the TTL", fh.statCalls.Load())
}
h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
h.state(t)
if fh.statCalls.Load() != 3 {
t.Fatalf("status calls = %d, want 3 after a request", fh.statCalls.Load())
}
}
func TestRedirectDoesNotForwardToken(t *testing.T) {
var leaked atomic.Bool
other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
leaked.Store(true)
}
}))
t.Cleanup(other.Close)
fh := newFakeHook(t)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect)
}))
t.Cleanup(hook.Close)
h := newActionServer(t, func(a *config.Action) { a.Webhook.URL = hook.URL }, fh.srv.URL)
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != http.StatusBadGateway {
t.Fatalf("code = %d", rec.Code)
}
if leaked.Load() {
t.Fatal("bearer token forwarded across a redirect")
}
}
+14 -10
View File
@@ -10,13 +10,15 @@ import (
// apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub; // apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub;
// the UI gets counts, release info and the alerts that explain every non-green state. // the UI gets counts, release info and the alerts that explain every non-green state.
type apiState struct { type apiState struct {
Now time.Time `json:"now"`
LastRefresh time.Time `json:"lastRefresh"` LastRefresh time.Time `json:"lastRefresh"`
Now time.Time `json:"now"`
ActionsError string `json:"actionsError,omitempty"`
Actions []apiAction `json:"actions,omitempty"`
Repos []apiRepo `json:"repos"` Repos []apiRepo `json:"repos"`
Alerts []apiAlert `json:"alerts"` Alerts []apiAlert `json:"alerts"`
Thresholds apiThresholds `json:"thresholds"` Thresholds apiThresholds `json:"thresholds"`
Counts apiCounts `json:"counts"`
Scan apiScan `json:"scan"` Scan apiScan `json:"scan"`
Counts apiCounts `json:"counts"`
IntervalSeconds int64 `json:"intervalSeconds"` IntervalSeconds int64 `json:"intervalSeconds"`
Loaded bool `json:"loaded"` Loaded bool `json:"loaded"`
Refreshing bool `json:"refreshing"` Refreshing bool `json:"refreshing"`
@@ -85,13 +87,15 @@ type apiRelease struct {
} }
type apiAlert struct { type apiAlert struct {
Since time.Time `json:"since"` Since time.Time `json:"since"`
Repo string `json:"repo"` ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"`
Kind string `json:"kind"` Repo string `json:"repo"`
Severity string `json:"severity"` Kind string `json:"kind"`
Subject string `json:"subject"` Severity string `json:"severity"`
Detail string `json:"detail"` Subject string `json:"subject"`
URL string `json:"url"` Detail string `json:"detail"`
URL string `json:"url"`
Ref string `json:"ref,omitempty"`
} }
func seconds(d time.Duration) int64 { return int64(d / time.Second) } func seconds(d time.Duration) int64 { return int64(d / time.Second) }
@@ -130,7 +134,7 @@ func buildState(v collector.View, manualRefresh bool) apiState {
for _, a := range v.Alerts { for _, a := range v.Alerts {
st.Alerts = append(st.Alerts, apiAlert{ st.Alerts = append(st.Alerts, apiAlert{
Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject, Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject,
Detail: a.Detail, URL: a.URL, Since: a.Since, Detail: a.Detail, URL: a.URL, Ref: a.Ref, Since: a.Since,
}) })
} }
return st return st
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"context"
"net/http"
"strings"
"sync"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
// maxAutoSends caps webhook calls per refresh so a first start cannot flood the consumer.
const maxAutoSends = 5
type autoKey struct{ action, repo, url, ref string }
// AutoRunner fires auto actions once per (action, repo, url, head commit).
type AutoRunner struct {
actions *Actions
sent map[autoKey]struct{}
mu sync.Mutex
}
// NewAutoRunner returns a runner for the auto actions in a, or nil when none is configured.
func NewAutoRunner(a *Actions) *AutoRunner {
for _, rt := range a.list {
if rt.cfg.Auto {
return &AutoRunner{actions: a, sent: map[autoKey]struct{}{}}
}
}
return nil
}
// Run sends the webhooks that are due for the alerts in v and forgets the ones whose alert is gone.
func (r *AutoRunner) Run(ctx context.Context, v collector.View) {
r.mu.Lock()
defer r.mu.Unlock()
live := map[autoKey]struct{}{}
budget := maxAutoSends
for _, rt := range r.actions.list {
if !rt.cfg.Auto {
continue
}
for _, al := range v.Alerts {
if !rt.cfg.AppliesTo(al.Kind) || len(rt.cfg.Authors) > 0 && !rt.cfg.AuthorAllowed(al.Author) {
continue
}
k := autoKey{rt.cfg.ID, strings.ToLower(al.Repo), al.URL, al.Ref}
live[k] = struct{}{}
if _, done := r.sent[k]; done || budget == 0 || ctx.Err() != nil {
continue
}
budget--
if r.send(ctx, rt, al, v) {
r.sent[k] = struct{}{}
}
}
}
for k := range r.sent {
if _, ok := live[k]; !ok {
delete(r.sent, k)
}
}
}
// send posts one alert and reports whether it counts as delivered; 409 means busy and is retried.
func (r *AutoRunner) send(ctx context.Context, rt *actionRuntime, al model.Alert, v collector.View) bool {
a := r.actions
status, _, err := a.post(ctx, rt.cfg, newWebhookPayload(rt.cfg.ID, al, v.Now))
switch {
case err != nil:
a.count(rt.cfg.ID, resultAutoFailed)
a.log.Warn("auto action webhook failed", "action", rt.cfg.ID, "repo", al.Repo, "err", err)
return false
case status == http.StatusConflict:
a.count(rt.cfg.ID, resultAutoBusy)
a.log.Info("auto action busy, will retry", "action", rt.cfg.ID, "repo", al.Repo)
return false
case status/100 == 2:
a.count(rt.cfg.ID, resultAutoOK)
rt.invalidate()
a.log.Info("auto action sent", "action", rt.cfg.ID, "repo", al.Repo, "ref", al.Ref)
return true
}
result := resultAutoFailed
if status/100 == 4 {
result = resultAutoRefused
}
a.count(rt.cfg.ID, result)
a.log.Warn("auto action webhook answered", "action", rt.cfg.ID, "repo", al.Repo, "status", status)
return false
}
+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) != 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)
}
}
+19 -5
View File
@@ -41,6 +41,7 @@ type Server struct {
lastRepo map[string]time.Time lastRepo map[string]time.Time
now func() time.Time now func() time.Time
src Source src Source
actions *Actions
ui fs.FS ui fs.FS
metrics http.Handler metrics http.Handler
log *slog.Logger log *slog.Logger
@@ -64,6 +65,9 @@ func (s *Server) Handler() http.Handler {
if s.manualRefresh { if s.manualRefresh {
mux.HandleFunc("POST /refresh", s.refresh) mux.HandleFunc("POST /refresh", s.refresh)
} }
if s.actions != nil {
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
}
return securityHeaders(mux) return securityHeaders(mux)
} }
@@ -99,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, s.ui, name) http.ServeFileFS(w, r, s.ui, name)
} }
func (s *Server) state(w http.ResponseWriter, _ *http.Request) { func (s *Server) state(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil { st := buildState(s.src.View(), s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(st); err != nil {
s.log.Error("encode state", "err", err) s.log.Error("encode state", "err", err)
} }
} }
@@ -117,8 +123,7 @@ func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
} }
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget. if crossSite(r) {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
http.Error(w, "cross-site refresh refused", http.StatusForbidden) http.Error(w, "cross-site refresh refused", http.StatusForbidden)
return return
} }
@@ -140,6 +145,13 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
} }
// crossSite reports whether the browser labelled the request as cross-site, so another page
// cannot spend the API budget or fire a webhook.
func crossSite(r *http.Request) bool {
site := r.Header.Get("Sec-Fetch-Site")
return site != "" && site != "same-origin" && site != "none"
}
// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name. // repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
type repoRefreshResponse struct { type repoRefreshResponse struct {
Repo string `json:"repo"` Repo string `json:"repo"`
@@ -185,7 +197,9 @@ func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil { st := buildState(v, s.manualRefresh)
s.decorate(r.Context(), &st)
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: st}); err != nil {
s.log.Error("encode repo refresh", "err", err) s.log.Error("encode repo refresh", "err", err)
} }
} }
+8
View File
@@ -80,6 +80,14 @@ func run(ctx context.Context, cfgPath string, log *slog.Logger) error {
} }
srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log) srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log)
if len(cfg.Actions) > 0 {
actions := web.NewActions(cfg.Actions, log, reg)
srv.WithActions(actions)
if auto := web.NewAutoRunner(actions); auto != nil {
coll.OnRefresh(auto.Run)
}
}
go coll.Run(ctx) go coll.Run(ctx)
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout} hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}
+1 -1
View File
@@ -43,5 +43,5 @@
"vitest": "^5.0.1", "vitest": "^5.0.1",
"vue-tsc": "^3.3.11" "vue-tsc": "^3.3.11"
}, },
"packageManager": "[email protected]1.0" "packageManager": "[email protected]8.3"
} }
+584 -1496
View File
File diff suppressed because it is too large Load Diff
+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
View File
@@ -2,6 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue' import AgeMeter from '@/components/AgeMeter.vue'
import RowActions from '@/components/RowActions.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format' import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds' import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types' import type { Row } from '@/lib/types'
@@ -33,6 +34,7 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere"> <p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }} {{ shorten(row.detail, 120) }}
</p> </p>
<RowActions v-if="row.actions?.length" :row="row" />
</div> </div>
<div <div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5" class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
+121
View File
@@ -0,0 +1,121 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AttentionRow from './AttentionRow.vue'
import { alert, hoursAgo, repo, state } from '@/test/fixtures'
import { buildBlocks } from '@/stores/tracker'
import type { ActionDef, Alert, Row } from '@/lib/types'
const rerun: ActionDef = {
id: 'rerun',
label: 'Re-run',
confirm: 'Run this action?',
kinds: ['workflow_failed'],
}
const rowFor = (actions: ActionDef[], over: Partial<Alert> = {}): Row =>
buildBlocks(state({ repos: [repo()], alerts: [alert(over)], actions }))[0].rows[0]
describe('buildBlocks with actions', () => {
it('leaves rows untouched when no actions are configured', () => {
const r = rowFor([])
expect('actions' in r).toBe(false)
expect('actionStatus' in r).toBe(false)
})
it.each([
['kind listed', ['workflow_failed'], true],
['kind not listed', ['pr_waiting'], false],
['empty kinds match everything', [], true],
])('%s', (_name, kinds, shown) => {
const r = rowFor([{ ...rerun, kinds }])
expect(Boolean(r.actions?.length)).toBe(shown)
})
it('carries the status of the alert', () => {
const r = rowFor([rerun], { actionStatus: { rerun: { state: 'running' } } })
expect(r.actionStatus).toEqual({ rerun: { state: 'running' } })
})
})
describe('RowActions', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => {
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
const mountRow = (row: Row) =>
mount(AttentionRow, {
props: { row, nowMs: Date.parse(hoursAgo(0)), critSeconds: 604800 },
attachTo: document.body,
})
it('shows no button without actions', () => {
const w = mountRow(rowFor([]))
expect(w.find('[data-test=row-actions]').exists()).toBe(false)
})
it('shows a button labelled from the config', () => {
const w = mountRow(rowFor([rerun]))
expect(w.find('[data-test=action-rerun]').text()).toBe('Re-run')
expect(w.find('[data-test=action-status]').exists()).toBe(false)
})
it('asks for confirmation, then posts the alert', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
new Response(
JSON.stringify(
url.startsWith('/api/actions/')
? { state: 'queued', link: 'https://ci.example.test/9' }
: state(),
),
{ status: 200 },
),
),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([rerun]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(document.body.textContent).toContain('Run this action?')
expect(fetchMock).not.toHaveBeenCalled()
;(document.body.querySelector('[data-test=action-confirm]') as HTMLElement).click()
await vi.waitFor(() => expect(w.find('[data-test=action-status]').exists()).toBe(true))
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('/api/actions/rerun')
expect(JSON.parse(init.body as string)).toEqual({
repo: 'o/r',
url: 'https://github.com/o/r/actions/runs/1',
})
await vi.waitFor(() =>
expect(w.get('[data-test=action-status] a').attributes('href')).toBe(
'https://ci.example.test/9',
),
)
})
it('posts at once when the action has no confirm text', async () => {
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(() =>
Promise.resolve(new Response('{}', { status: 200 })),
)
vi.stubGlobal('fetch', fetchMock)
const w = mountRow(rowFor([{ ...rerun, confirm: undefined }]))
await w.get('[data-test=action-rerun]').trigger('click')
expect(fetchMock).toHaveBeenCalledWith('/api/actions/rerun', expect.anything())
})
it('shows the status reported by the server as text, or as a link', () => {
const plain = mountRow(
rowFor([rerun], { actionStatus: { rerun: { state: 'done', label: 'Finished' } } }),
)
expect(plain.get('[data-test=action-status]').text()).toBe('Finished')
expect(plain.find('[data-test=action-status] a').exists()).toBe(false)
const linked = mountRow(
rowFor([rerun], {
actionStatus: { rerun: { state: 'done', link: 'https://x.example.test/' } },
}),
)
expect(linked.get('[data-test=action-status] a').text()).toBe('done')
})
})
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { actionKey, useTrackerStore } from '@/stores/tracker'
import type { ActionDef, ActionStatus, Row } from '@/lib/types'
const props = defineProps<{ row: Row }>()
const store = useTrackerStore()
// `asking` outlives the dialog so the confirm click still knows which action to run.
const asking = ref<ActionDef | null>(null)
const open = ref(false)
const keyOf = (a: ActionDef) => actionKey(a.id, props.row.repo, props.row.url)
const busy = (a: ActionDef) => store.runningActions.includes(keyOf(a))
const statusOf = (a: ActionDef): ActionStatus | undefined =>
props.row.actionStatus?.[a.id] ?? store.localStatus[keyOf(a)]
function run(a: ActionDef): void {
void store.runAction(a.id, props.row.repo, props.row.url)
}
function press(a: ActionDef): void {
if (!a.confirm) return run(a)
asking.value = a
open.value = true
}
</script>
<template>
<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"
: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" />
{{ a.label }}
</Button>
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
<Badge v-else-if="statusOf(a)" variant="secondary" data-test="action-status">
<a
v-if="statusOf(a)?.link"
:href="statusOf(a)?.link"
class="underline-offset-4 hover:underline"
>{{ statusOf(a)?.label || statusOf(a)?.state }}</a
>
<template v-else>{{ statusOf(a)?.label || statusOf(a)?.state }}</template>
</Badge>
</template>
<AlertDialog v-model:open="open">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ asking?.label }}</AlertDialogTitle>
<AlertDialogDescription>{{ asking?.confirm }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction data-test="action-confirm" @click="asking && run(asking)">
{{ asking?.label }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
+1
View File
@@ -125,6 +125,7 @@ describe('AgeMeter', () => {
describe('AttentionRow', () => { describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({ const row = (over: Partial<Row> = {}): Row => ({
repo: 'o/r',
kind: 'pr_waiting', kind: 'pr_waiting',
subject: '#5 <b>bold</b> title', subject: '#5 <b>bold</b> title',
detail: 'open 9d', detail: 'open 9d',
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(buttonVariants({ variant: 'outline' }), props.class)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
+8
View File
@@ -0,0 +1,8 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
+20 -1
View File
@@ -1,4 +1,4 @@
import type { RepoRefreshResult, TrackerState } from './types' import type { ActionStatus, RepoRefreshResult, TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> { export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } }) const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
@@ -32,3 +32,22 @@ export async function requestRepoRefresh(fullName: string): Promise<RepoRefreshR
if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`) if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
return (await res.json()) as RepoRefreshResult return (await res.json()) as RepoRefreshResult
} }
/** Sends an alert to the webhook behind an action; returns the status the webhook reported, if any. */
export async function requestAction(
id: string,
repo: string,
url: string,
): Promise<ActionStatus | null> {
const res = await fetch(`/api/actions/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo, url }),
})
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `action ${id} failed: HTTP ${res.status}`)
}
const status = (await res.json().catch(() => null)) as ActionStatus | null
return status && typeof status.state === 'string' ? status : null
}
+21
View File
@@ -24,6 +24,21 @@ export interface Repo {
release: Release | null release: Release | null
} }
/** An operator-defined button, as listed by the server. An empty `kinds` matches every alert kind. */
export interface ActionDef {
id: string
label: string
confirm?: string
kinds: string[]
}
/** Status of an action for one alert; `link` is an http(s) address when present. */
export interface ActionStatus {
state: string
label?: string
link?: string
}
export interface Alert { export interface Alert {
repo: string repo: string
kind: string kind: string
@@ -32,6 +47,7 @@ export interface Alert {
detail: string detail: string
url: string url: string
since: string since: string
actionStatus?: Record<string, ActionStatus>
} }
export interface Thresholds { export interface Thresholds {
@@ -54,6 +70,8 @@ export interface TrackerState {
thresholds: Thresholds thresholds: Thresholds
repos: Repo[] repos: Repo[]
alerts: Alert[] alerts: Alert[]
actions?: ActionDef[]
actionsError?: string
} }
/** Answer to a single-repository refresh: the state after it, plus the repo name. */ /** Answer to a single-repository refresh: the state after it, plus the repo name. */
@@ -61,12 +79,15 @@ export type RepoRefreshResult = TrackerState & { repo: string }
/** One line in a repo block: every alert that targets the same URL, merged. */ /** One line in a repo block: every alert that targets the same URL, merged. */
export interface Row { export interface Row {
repo: string
kind: string kind: string
subject: string subject: string
detail: string detail: string
url: string url: string
since: string since: string
severity: Exclude<Severity, 'ok'> severity: Exclude<Severity, 'ok'>
actions?: ActionDef[]
actionStatus?: Record<string, ActionStatus>
} }
export interface Block { export interface Block {
+27
View File
@@ -329,3 +329,30 @@ describe('tracker store', () => {
}) })
}) })
}) })
describe('runAction', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.unstubAllGlobals())
it('shows the server error and keeps no status when the webhook fails', async () => {
mockFetch((url) =>
url.startsWith('/api/actions/')
? json({ error: 'action webhook answered HTTP 500' }, 500)
: json(state()),
)
const store = useTrackerStore()
await store.runAction('rerun', 'o/r', 'u')
expect(store.notice).toContain('HTTP 500')
expect(store.localStatus).toEqual({})
expect(store.runningActions).toEqual([])
})
it('ignores a second press while the first request runs', async () => {
const fn = mockFetch((url) =>
url.startsWith('/api/actions/') ? json({ state: 'queued' }) : json(state()),
)
const store = useTrackerStore()
await Promise.all([store.runAction('rerun', 'o/r', 'u'), store.runAction('rerun', 'o/r', 'u')])
expect(fn.mock.calls.filter(([u]) => u.startsWith('/api/actions/'))).toHaveLength(1)
})
})
+40 -2
View File
@@ -1,6 +1,12 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { fetchState, RescanLimitedError, requestRefresh, requestRepoRefresh } from '@/lib/api' import {
fetchState,
RescanLimitedError,
requestAction,
requestRefresh,
requestRepoRefresh,
} from '@/lib/api'
import { import {
activeFilterCount, activeFilterCount,
applyFilters, applyFilters,
@@ -11,7 +17,7 @@ import {
type Filters, type Filters,
} from '@/lib/filters' } from '@/lib/filters'
import { DATA_KINDS } from '@/lib/kinds' import { DATA_KINDS } from '@/lib/kinds'
import type { Block, Row, TrackerState } from '@/lib/types' import type { ActionStatus, Block, Row, TrackerState } from '@/lib/types'
/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */ /** While a rescan runs the server takes ~40s, so check back often instead of every minute. */
export const SCAN_POLL_MS = 2000 export const SCAN_POLL_MS = 2000
@@ -21,6 +27,9 @@ const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const
const sinceMs = (since: string) => Date.parse(since) || 0 const sinceMs = (since: string) => Date.parse(since) || 0
/** Identifies one action on one alert; the separators cannot occur in an id or a repo name. */
export const actionKey = (id: string, repo: string, url: string) => `${id}\n${repo}\n${url}`
/** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */ /** Groups alerts into one row per target URL and one block per unhealthy repo, newest problem first. */
export function buildBlocks(state: TrackerState): Block[] { export function buildBlocks(state: TrackerState): Block[] {
const rows = new Map<string, Row>() const rows = new Map<string, Row>()
@@ -37,7 +46,12 @@ export function buildBlocks(state: TrackerState): Block[] {
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
continue continue
} }
const actions = (state.actions ?? []).filter(
(a) => a.kinds.length === 0 || a.kinds.includes(alert.kind),
)
rows.set(key, { rows.set(key, {
repo: alert.repo,
...(actions.length > 0 && { actions, actionStatus: alert.actionStatus }),
kind: alert.kind, kind: alert.kind,
subject: alert.subject, subject: alert.subject,
detail: alert.detail, detail: alert.detail,
@@ -105,6 +119,10 @@ export const useTrackerStore = defineStore('tracker', () => {
/** Screen-reader text for a finished single-repository refresh. */ /** Screen-reader text for a finished single-repository refresh. */
const announcement = ref('') const announcement = ref('')
const refreshingRepos = ref<string[]>([]) const refreshingRepos = ref<string[]>([])
/** Action keys with a request in flight. */
const runningActions = ref<string[]>([])
/** Status answered by a webhook, shown until the status list reports one. */
const localStatus = ref<Record<string, ActionStatus>>({})
const skewMs = ref(0) const skewMs = ref(0)
const tick = ref(Date.now()) const tick = ref(Date.now())
@@ -209,9 +227,29 @@ export const useTrackerStore = defineStore('tracker', () => {
} }
} }
/** Sends one alert to an action's webhook, then reloads the state through the existing poll path. */
async function runAction(id: string, repo: string, url: string): Promise<void> {
const key = actionKey(id, repo, url)
if (runningActions.value.includes(key)) return
runningActions.value = [...runningActions.value, key]
notice.value = ''
try {
const status = await requestAction(id, repo, url)
localStatus.value = { ...localStatus.value, [key]: status ?? { state: 'Requested' } }
await load()
} catch (e) {
notice.value = e instanceof Error ? e.message : String(e)
} finally {
runningActions.value = runningActions.value.filter((k) => k !== key)
}
}
return { return {
state, state,
error, error,
runningActions,
localStatus,
runAction,
loading, loading,
notice, notice,
announcement, announcement,