mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65fed1546 | ||
|
|
4b9b6303de | ||
|
|
021dc2e8f9 | ||
|
|
fb31e31bec | ||
|
|
0585326fea | ||
|
|
5bca4c38f0 | ||
|
|
eff9559aee | ||
|
|
5c5416f6f0 | ||
|
|
a0fd6d6856 | ||
|
|
ac5eb2f16a | ||
|
|
66ed84bdbf | ||
|
|
e6f28a223a | ||
|
|
0a2469ef7d | ||
|
|
06922abe05 | ||
|
|
2100458973 | ||
|
|
6b08d09431 | ||
|
|
78a6723143 | ||
|
|
c2ce5391c0 | ||
|
|
c79ecb728f | ||
|
|
23bbbedf59 | ||
|
|
5824c4dd10 | ||
|
|
32de5d6704 | ||
|
|
3ce2c50f5a |
@@ -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
|
||||||
@@ -17,11 +17,11 @@ Release archives and the image checksums are signed with cosign.
|
|||||||
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
|
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
|
||||||
|
|
||||||
- Failing workflows, including scheduled and other background ones, with a link to the run.
|
- Failing workflows, including scheduled and other background ones, with a link to the run.
|
||||||
- Pull requests that wait too long or fail their checks, with links and age.
|
- Pull requests, one line each: new ones (`pr_fresh`), ones that wait too long (`pr_waiting`) and ones that fail their checks.
|
||||||
- Stale issues, as one line per project.
|
- Open issues, one line each: new ones (`issue_fresh`) and ones without recent activity (`issue_stale`).
|
||||||
- Projects that failed to refresh, or loaded only in part.
|
- Projects that failed to refresh, or loaded only in part.
|
||||||
|
|
||||||
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Healthy items do not appear anywhere else.
|
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Every open non-draft pull request and every open issue raises a warning, so a project with open work is never green.
|
||||||
|
|
||||||
### Search and filters
|
### Search and filters
|
||||||
|
|
||||||
@@ -48,7 +48,9 @@ A side panel lists every project with its latest release (or newest tag), a link
|
|||||||
| Non-draft PR open longer than `pr_warn_after` | warn |
|
| Non-draft PR open longer than `pr_warn_after` | warn |
|
||||||
| PR checks failing | warn |
|
| PR checks failing | warn |
|
||||||
| Workflow queued or running longer than `workflow_stuck_after` | warn |
|
| Workflow queued or running longer than `workflow_stuck_after` | warn |
|
||||||
| Open issues without activity for `issue_stale_after` (one alert per repo) | warn |
|
| Non-draft PR open less than `pr_warn_after` (`pr_fresh`) | warn |
|
||||||
|
| Open issue without activity for `issue_stale_after` (`issue_stale`, one alert per issue) | warn |
|
||||||
|
| Open issue with recent activity (`issue_fresh`, one alert per issue) | warn |
|
||||||
| A section failed to load, for example a missing App permission | warn |
|
| A section failed to load, for example a missing App permission | warn |
|
||||||
| Data older than `data_stale_after` | warn |
|
| Data older than `data_stale_after` | warn |
|
||||||
|
|
||||||
@@ -70,12 +72,59 @@ 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.
|
||||||
|
|
||||||
| Metric | Labels |
|
| Metric | Labels |
|
||||||
| -------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
| ----------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||||
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
|
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
|
||||||
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
|
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
|
||||||
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
|
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
|
||||||
@@ -84,6 +133,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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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.25.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.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.49.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,36 +2,34 @@ 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.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
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/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
|
||||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
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.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
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=
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -137,25 +155,25 @@ func (c *Collector) Refresh(ctx context.Context) {
|
|||||||
c.limited.Store(false)
|
c.limited.Store(false)
|
||||||
c.scanDone.Store(0)
|
c.scanDone.Store(0)
|
||||||
c.scanTotal.Store(int64(len(c.names)))
|
c.scanTotal.Store(int64(len(c.names)))
|
||||||
results := make([]model.Repo, len(c.names))
|
|
||||||
sem := make(chan struct{}, c.concurrency)
|
sem := make(chan struct{}, c.concurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, name := range c.names {
|
for _, name := range c.names {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
results[i] = c.refreshOne(ctx, name, start, true)
|
r := c.refreshOne(ctx, name, start, true)
|
||||||
|
// Publish per repo so a running scan shows new and resolved alerts as they land.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.repos[r.FullName] = r
|
||||||
|
c.mu.Unlock()
|
||||||
c.scanDone.Add(1)
|
c.scanDone.Add(1)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
for _, r := range results {
|
|
||||||
c.repos[r.FullName] = r
|
|
||||||
}
|
|
||||||
c.lastRefresh = start
|
c.lastRefresh = start
|
||||||
c.lastDur = c.now().Sub(start)
|
c.lastDur = c.now().Sub(start)
|
||||||
c.loaded = true
|
c.loaded = true
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -66,34 +66,36 @@ func TestEvaluate(t *testing.T) {
|
|||||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
|
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{KindPRFresh: model.Warn}, model.Warn},
|
||||||
"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{KindPRFresh: model.Warn, KindPRChecks: model.Warn}, model.Warn},
|
||||||
"issues stale aggregated": {func(r *model.Repo) {
|
"issue stale": {func(r *model.Repo) {
|
||||||
old := t0.Add(-40 * 24 * time.Hour)
|
r.Issues = []model.Issue{{Number: 1, CreatedAt: t0.Add(-60 * 24 * time.Hour), UpdatedAt: t0.Add(-40 * 24 * time.Hour)}}
|
||||||
r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}}
|
}, map[string]model.Severity{KindIssueStale: model.Warn}, model.Warn},
|
||||||
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
|
"issue fresh": {func(r *model.Repo) {
|
||||||
|
r.Issues = []model.Issue{{Number: 1, CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0}}
|
||||||
|
}, map[string]model.Severity{KindIssueFresh: model.Warn}, model.Warn},
|
||||||
"archived repo skips work alerts": {func(r *model.Repo) {
|
"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,24 +112,37 @@ func TestEvaluate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withoutPROpen drops the informational pr_open alerts so tests of other kinds stay focused.
|
||||||
|
func withoutPROpen(in []model.Alert) []model.Alert {
|
||||||
|
var out []model.Alert
|
||||||
|
for _, a := range in {
|
||||||
|
if a.Kind != KindPROpen {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvaluateDisabledThresholds(t *testing.T) {
|
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 {
|
for _, a := range withoutPROpen(Evaluate(&r, t0, off)) {
|
||||||
|
if a.Kind != KindPRFresh && a.Kind != KindIssueFresh {
|
||||||
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) != 1 || a[0].Kind != KindPRFresh {
|
||||||
t.Fatalf("unexpected %+v", a)
|
t.Fatalf("a PR below the warn threshold must be fresh: %+v", a)
|
||||||
}
|
}
|
||||||
if a := Evaluate(&r, t0.Add(10*time.Hour), cfg); len(a) != 1 || a[0].Severity != model.Warn {
|
if a := withoutPROpen(Evaluate(&r, t0.Add(10*time.Hour), cfg)); len(a) != 1 || a[0].Kind != KindPRWaiting {
|
||||||
t.Fatalf("PR must cross the warn threshold as time passes: %+v", a)
|
t.Fatalf("PR must turn from fresh to waiting as time passes: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +245,7 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
|||||||
c := &model.Run{Status: "completed", Conclusion: "failure"}
|
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 +255,8 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
|||||||
if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" {
|
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 +473,125 @@ 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 !tc.want {
|
||||||
|
if len(alerts) != 0 {
|
||||||
|
t.Fatalf("alerts = %+v, want none", alerts)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := model.Alert{Severity: model.OK, Repo: "o/r", Kind: KindPROpen, Subject: "#7 fix", Detail: "open for 5h",
|
||||||
|
URL: tc.pr.URL, Ref: "abc123", Author: "alice", Since: tc.pr.CreatedAt}
|
||||||
|
if alerts = onlyPROpen(alerts); len(alerts) != 1 || alerts[0] != want {
|
||||||
|
t.Fatalf("alerts = %+v, want [%+v]", alerts, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlyPROpen(in []model.Alert) []model.Alert {
|
||||||
|
var out []model.Alert
|
||||||
|
for _, a := range in {
|
||||||
|
if a.Kind == KindPROpen {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPROpenHasNoSeverityOfItsOwn(t *testing.T) {
|
||||||
|
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||||
|
return model.Repo{FullName: name, PRs: []model.PullRequest{{Number: 1, HeadSHA: "s", CreatedAt: t0}}}, nil
|
||||||
|
}}
|
||||||
|
c := newCollector(f, "o/a")
|
||||||
|
c.Refresh(context.Background())
|
||||||
|
v := c.View()
|
||||||
|
if len(onlyPROpen(v.Alerts)) != 1 || v.Alerts[0].Severity != model.Warn || v.Crit != 0 || v.Warn != 1 {
|
||||||
|
t.Fatalf("view = %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRepoRunsRefreshHooksWithTheNewView(t *testing.T) {
|
||||||
|
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||||
|
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, 3)}, nil
|
||||||
|
}}
|
||||||
|
c := newCollector(f, "o/a")
|
||||||
|
got := make(chan View, 1)
|
||||||
|
c.OnRefresh(func(_ context.Context, v View) { got <- v })
|
||||||
|
if _, err := c.RefreshRepo(context.Background(), "o/a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case v := <-got:
|
||||||
|
if len(v.Repos) != 1 || len(v.Repos[0].Issues) != 3 {
|
||||||
|
t.Fatalf("hook saw a stale view: %+v", v.Repos)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("refresh hook did not run after a repository refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) {
|
||||||
|
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
|
||||||
|
called := make(chan struct{}, 1)
|
||||||
|
c.OnRefresh(func(context.Context, View) { called <- struct{}{} })
|
||||||
|
if _, err := c.RefreshRepo(context.Background(), "unknown/x"); err == nil {
|
||||||
|
t.Fatal("unknown repository must fail")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-called:
|
||||||
|
t.Fatal("hook ran for a refused refresh")
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshPublishesEachRepoBeforeTheScanEnds(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||||
|
if name == "o/slow" {
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return model.Repo{FullName: name, Err: ""}, nil
|
||||||
|
}}
|
||||||
|
cfg := &config.Config{Repos: []string{"o/fast", "o/slow"}, RefreshInterval: time.Hour, Concurrency: 2, Thresholds: th()}
|
||||||
|
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.now = func() time.Time { return t0 }
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { c.Refresh(context.Background()); close(done) }()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if v := c.View(); len(v.Repos) == 1 && v.Repos[0].FullName == "o/fast" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
v := c.View()
|
||||||
|
if len(v.Repos) != 1 || v.Repos[0].FullName != "o/fast" || !v.Refreshing || v.Loaded {
|
||||||
|
t.Fatalf("mid-scan view: repos=%d refreshing=%v loaded=%v", len(v.Repos), v.Refreshing, v.Loaded)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
if v := c.View(); len(v.Repos) != 2 || !v.Loaded {
|
||||||
|
t.Fatalf("after scan: repos=%d loaded=%v", len(v.Repos), v.Loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ const (
|
|||||||
KindWorkflowStuck = "workflow_stuck"
|
KindWorkflowStuck = "workflow_stuck"
|
||||||
KindPRWaiting = "pr_waiting"
|
KindPRWaiting = "pr_waiting"
|
||||||
KindPRChecks = "pr_checks_failing"
|
KindPRChecks = "pr_checks_failing"
|
||||||
KindIssuesStale = "issues_stale"
|
KindPRFresh = "pr_fresh"
|
||||||
|
KindIssueStale = "issue_stale"
|
||||||
|
KindIssueFresh = "issue_fresh"
|
||||||
|
// KindPROpen is informational plumbing for automatic actions; it has OK severity so it never moves health or counts.
|
||||||
|
KindPROpen = "pr_open"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
|
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
|
||||||
@@ -62,32 +66,27 @@ func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert
|
|||||||
}
|
}
|
||||||
age := now.Sub(pr.CreatedAt)
|
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)
|
||||||
case th.PRWarnAfter > 0 && age >= th.PRWarnAfter:
|
case th.PRWarnAfter > 0 && age >= th.PRWarnAfter:
|
||||||
add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
|
add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
|
||||||
|
default:
|
||||||
|
add(model.Warn, KindPRFresh, subject, "new, open "+Age(age), pr.URL, pr.CreatedAt)
|
||||||
}
|
}
|
||||||
if pr.Checks.State == model.ChecksFail {
|
if pr.Checks.State == model.ChecksFail {
|
||||||
add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt)
|
add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if th.IssueStaleAfter > 0 {
|
|
||||||
var stale int
|
|
||||||
var oldest time.Time
|
|
||||||
for _, is := range r.Issues {
|
for _, is := range r.Issues {
|
||||||
if now.Sub(is.UpdatedAt) >= th.IssueStaleAfter {
|
subject := fmt.Sprintf("#%d %s", is.Number, is.Title)
|
||||||
stale++
|
if idle := now.Sub(is.UpdatedAt); th.IssueStaleAfter > 0 && idle >= th.IssueStaleAfter {
|
||||||
if oldest.IsZero() || is.UpdatedAt.Before(oldest) {
|
add(model.Warn, KindIssueStale, subject, "no activity for "+Age(idle), is.URL, is.UpdatedAt)
|
||||||
oldest = is.UpdatedAt
|
} else {
|
||||||
}
|
add(model.Warn, KindIssueFresh, subject, "new, open "+Age(now.Sub(is.CreatedAt)), is.URL, is.CreatedAt)
|
||||||
}
|
|
||||||
}
|
|
||||||
if stale > 0 {
|
|
||||||
add(model.Warn, KindIssuesStale, fmt.Sprintf("%d stale issues", stale),
|
|
||||||
"no activity for "+Age(th.IssueStaleAfter)+" or more",
|
|
||||||
r.URL+"/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc", oldest)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return alerts
|
return alerts
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-3
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||||
@@ -15,6 +16,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
maxFailingNames = 5
|
maxFailingNames = 5
|
||||||
runsPerWorkflow = 5
|
runsPerWorkflow = 5
|
||||||
|
// freshRunsWindowDays bounds the created filter of the confirming run query.
|
||||||
|
freshRunsWindowDays = 30
|
||||||
maxWorkflows = 100
|
maxWorkflows = 100
|
||||||
eventPullRequest = "pull_request"
|
eventPullRequest = "pull_request"
|
||||||
eventPullReqTgt = "pull_request_target"
|
eventPullReqTgt = "pull_request_target"
|
||||||
@@ -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 {
|
||||||
|
Repo *repoRef `json:"repo"`
|
||||||
SHA string `json:"sha"`
|
SHA string `json:"sha"`
|
||||||
} `json:"head"`
|
} `json:"head"`
|
||||||
|
Base struct {
|
||||||
|
Repo *repoRef `json:"repo"`
|
||||||
|
} `json:"base"`
|
||||||
Labels []labelRef `json:"labels"`
|
Labels []labelRef `json:"labels"`
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Draft bool `json:"draft"`
|
Draft bool `json:"draft"`
|
||||||
@@ -141,13 +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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ type PullRequest struct {
|
|||||||
Checks CheckSummary
|
Checks CheckSummary
|
||||||
Number int
|
Number int
|
||||||
Draft bool
|
Draft bool
|
||||||
|
// Fork is true when the head repository differs from the base or was deleted.
|
||||||
|
Fork bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckSummary struct {
|
type CheckSummary struct {
|
||||||
@@ -139,6 +141,10 @@ type Alert struct {
|
|||||||
Subject string
|
Subject string
|
||||||
Detail string
|
Detail string
|
||||||
URL string
|
URL string
|
||||||
|
// Ref is the head commit of a pr_open alert; empty for other kinds.
|
||||||
|
Ref string
|
||||||
|
// Author is the pull request author of a pr_open alert; it is not part of the JSON API.
|
||||||
|
Author string
|
||||||
Severity Severity
|
Severity Severity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
// A client that disconnects mid-request must not fail the fetch that every later request reuses.
|
||||||
|
rows, err := a.fetchStatus(context.WithoutCancel(ctx), rt.cfg)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusFetchSurvivesCancelledRequest(t *testing.T) {
|
||||||
|
fh := newFakeHook(t)
|
||||||
|
fh.statBody = `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`
|
||||||
|
h := newActionServer(t, nil, fh.srv.URL)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
rows, err := h.actions.statusRows(ctx, h.actions.list[0])
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("rows=%d err=%v, want the fetch to complete despite the cancelled request", len(rows), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -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"`
|
||||||
@@ -86,12 +88,14 @@ type apiRelease struct {
|
|||||||
|
|
||||||
type apiAlert struct {
|
type apiAlert struct {
|
||||||
Since time.Time `json:"since"`
|
Since time.Time `json:"since"`
|
||||||
|
ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Severity string `json:"severity"`
|
Severity string `json:"severity"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
|
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||||
|
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAutoSends caps webhook calls per refresh so a first start cannot flood the consumer.
|
||||||
|
const maxAutoSends = 5
|
||||||
|
|
||||||
|
type autoKey struct{ action, repo, url, ref string }
|
||||||
|
|
||||||
|
// AutoRunner fires auto actions once per (action, repo, url, head commit).
|
||||||
|
type AutoRunner struct {
|
||||||
|
actions *Actions
|
||||||
|
sent map[autoKey]struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAutoRunner returns a runner for the auto actions in a, or nil when none is configured.
|
||||||
|
func NewAutoRunner(a *Actions) *AutoRunner {
|
||||||
|
for _, rt := range a.list {
|
||||||
|
if rt.cfg.Auto {
|
||||||
|
return &AutoRunner{actions: a, sent: map[autoKey]struct{}{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sends the webhooks that are due for the alerts in v and forgets the ones whose alert is gone.
|
||||||
|
func (r *AutoRunner) Run(ctx context.Context, v collector.View) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
live := map[autoKey]struct{}{}
|
||||||
|
budget := maxAutoSends
|
||||||
|
for _, rt := range r.actions.list {
|
||||||
|
if !rt.cfg.Auto {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, al := range v.Alerts {
|
||||||
|
if !rt.cfg.AppliesTo(al.Kind) || len(rt.cfg.Authors) > 0 && !rt.cfg.AuthorAllowed(al.Author) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := autoKey{rt.cfg.ID, strings.ToLower(al.Repo), al.URL, al.Ref}
|
||||||
|
live[k] = struct{}{}
|
||||||
|
if _, done := r.sent[k]; done || budget == 0 || ctx.Err() != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
budget--
|
||||||
|
if r.send(ctx, rt, al, v) {
|
||||||
|
r.sent[k] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range r.sent {
|
||||||
|
if _, ok := live[k]; !ok {
|
||||||
|
delete(r.sent, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send posts one alert and reports whether it counts as delivered; 409 means busy and is retried.
|
||||||
|
func (r *AutoRunner) send(ctx context.Context, rt *actionRuntime, al model.Alert, v collector.View) bool {
|
||||||
|
a := r.actions
|
||||||
|
status, _, err := a.post(ctx, rt.cfg, newWebhookPayload(rt.cfg.ID, al, v.Now))
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
a.count(rt.cfg.ID, resultAutoFailed)
|
||||||
|
a.log.Warn("auto action webhook failed", "action", rt.cfg.ID, "repo", al.Repo, "err", err)
|
||||||
|
return false
|
||||||
|
case status == http.StatusConflict:
|
||||||
|
a.count(rt.cfg.ID, resultAutoBusy)
|
||||||
|
a.log.Info("auto action busy, will retry", "action", rt.cfg.ID, "repo", al.Repo)
|
||||||
|
return false
|
||||||
|
case status/100 == 2:
|
||||||
|
a.count(rt.cfg.ID, resultAutoOK)
|
||||||
|
rt.invalidate()
|
||||||
|
a.log.Info("auto action sent", "action", rt.cfg.ID, "repo", al.Repo, "ref", al.Ref)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
result := resultAutoFailed
|
||||||
|
if status/100 == 4 {
|
||||||
|
result = resultAutoRefused
|
||||||
|
}
|
||||||
|
a.count(rt.cfg.ID, result)
|
||||||
|
a.log.Warn("auto action webhook answered", "action", rt.cfg.ID, "repo", al.Repo, "status", status)
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||||
|
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||||
|
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func autoPR(n int, sha, author string, mut func(*model.PullRequest)) model.PullRequest {
|
||||||
|
p := model.PullRequest{Number: n, Title: "t", URL: prURL + strings.Repeat("x", n), Author: author, HeadSHA: sha, CreatedAt: now.Add(-time.Hour)}
|
||||||
|
if mut != nil {
|
||||||
|
mut(&p)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoView builds the view through the real alert evaluation so fork and draft rules are exercised.
|
||||||
|
func autoView(prs ...model.PullRequest) collector.View {
|
||||||
|
r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: now, PRs: prs}
|
||||||
|
alerts := collector.Evaluate(&r, now, config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1})
|
||||||
|
return collector.View{Now: now, Repos: []model.Repo{r}, Alerts: alerts}
|
||||||
|
}
|
||||||
|
|
||||||
|
type autoStep struct {
|
||||||
|
prs []model.PullRequest
|
||||||
|
code int
|
||||||
|
wantPosts int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRunner(t *testing.T) {
|
||||||
|
fork := func(p *model.PullRequest) { p.Fork = true }
|
||||||
|
draft := func(p *model.PullRequest) { p.Draft = true }
|
||||||
|
tests := map[string]struct {
|
||||||
|
authors []string
|
||||||
|
steps []autoStep
|
||||||
|
}{
|
||||||
|
"fork triggers once per sha": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 0},
|
||||||
|
}},
|
||||||
|
"draft skipped": {steps: []autoStep{{[]model.PullRequest{autoPR(1, "a", "x", draft)}, 200, 0}}},
|
||||||
|
"author filter": {authors: []string{"Alice"}, steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "bob", nil), autoPR(2, "b", "alice", nil)}, 200, 1},
|
||||||
|
}},
|
||||||
|
"once per sha": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||||
|
}},
|
||||||
|
"new sha resends": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "b", "x", nil)}, 201, 1},
|
||||||
|
}},
|
||||||
|
"reappearing alert resends": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
{nil, 200, 0},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
}},
|
||||||
|
"failure retries": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 500, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 400, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||||
|
}},
|
||||||
|
"409 retries": {steps: []autoStep{
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 409, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||||
|
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||||
|
}},
|
||||||
|
"cap per refresh": {steps: []autoStep{
|
||||||
|
{manyPRs(8), 200, maxAutoSends},
|
||||||
|
{manyPRs(8), 200, 3},
|
||||||
|
{manyPRs(8), 200, 0},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
hook := newFakeHook(t)
|
||||||
|
a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true, Authors: tc.authors,
|
||||||
|
Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}},
|
||||||
|
slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry())
|
||||||
|
runner := NewAutoRunner(a)
|
||||||
|
for i, st := range tc.steps {
|
||||||
|
hook.postCode = st.code
|
||||||
|
before := hook.postCalls.Load()
|
||||||
|
runner.Run(t.Context(), autoView(st.prs...))
|
||||||
|
if got := hook.postCalls.Load() - before; got != st.wantPosts {
|
||||||
|
t.Fatalf("step %d: posts = %d, want %d", i, got, st.wantPosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func manyPRs(n int) []model.PullRequest {
|
||||||
|
out := make([]model.PullRequest, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = autoPR(i+1, "s", "x", nil)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRunnerPayloadAuthAndMetrics(t *testing.T) {
|
||||||
|
hook := newFakeHook(t)
|
||||||
|
a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true,
|
||||||
|
Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}},
|
||||||
|
slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry())
|
||||||
|
NewAutoRunner(a).Run(t.Context(), autoView(autoPR(1, "abc", "x", nil)))
|
||||||
|
hook.mu.Lock()
|
||||||
|
defer hook.mu.Unlock()
|
||||||
|
if hook.lastAuth != "Bearer "+testToken {
|
||||||
|
t.Fatalf("auth = %q", hook.lastAuth)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"action":"review"`, `"kind":"pr_open"`, `"ref":"abc"`, `"url":"` + prURL + `x"`} {
|
||||||
|
if !strings.Contains(hook.lastBody, want) {
|
||||||
|
t.Fatalf("body %s lacks %s", hook.lastBody, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := testutil.ToFloat64(a.counter.WithLabelValues("review", resultAutoOK)); got != 1 {
|
||||||
|
t.Fatalf("auto_ok = %v, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAutoRunnerNilWithoutAutoActions(t *testing.T) {
|
||||||
|
a := NewActions([]config.Action{{ID: "x", Webhook: config.Webhook{URL: "http://h.test"}}}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||||
|
if NewAutoRunner(a) != nil {
|
||||||
|
t.Fatal("runner must be nil when no action is auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateCarriesPROpenRef(t *testing.T) {
|
||||||
|
st := buildState(autoView(autoPR(1, "abc", "x", nil)), true)
|
||||||
|
if len(st.Alerts) != 2 || st.Alerts[0].Kind != collector.KindPROpen || st.Alerts[0].Ref != "abc" || st.Alerts[0].Severity != "ok" || st.Alerts[1].Kind != collector.KindPRFresh {
|
||||||
|
t.Fatalf("alerts = %+v", st.Alerts)
|
||||||
|
}
|
||||||
|
if st.Repos[0].Health != "warn" {
|
||||||
|
t.Fatalf("health = %s, want warn", st.Repos[0].Health)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-5
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+2
-2
@@ -37,11 +37,11 @@
|
|||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^20.14.5",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.9",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.70.1",
|
"typescript-eslint": "^8.70.1",
|
||||||
"vite": "^8.3.1",
|
"vite": "^8.3.1",
|
||||||
"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.5"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+665
-1586
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "pnpm 11 cannot switch to the pnpm 12 native binary: npx runs it with node",
|
||||||
|
"matchDepTypes": ["packageManager"],
|
||||||
|
"matchPackageNames": ["pnpm"],
|
||||||
|
"allowedVersions": "<12"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -116,7 +116,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
|
class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 xl:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
|
||||||
>
|
>
|
||||||
<AttentionList
|
<AttentionList
|
||||||
ref="list"
|
ref="list"
|
||||||
@@ -133,7 +133,7 @@ onBeforeUnmount(() => {
|
|||||||
@clear="store.clearFilters()"
|
@clear="store.clearFilters()"
|
||||||
@refresh="store.refreshRepo"
|
@refresh="store.refreshRepo"
|
||||||
/>
|
/>
|
||||||
<aside class="lg:sticky lg:top-6 lg:self-start">
|
<aside class="xl:sticky xl:top-6 xl:self-start">
|
||||||
<ProjectList
|
<ProjectList
|
||||||
:repos="visibleRepos"
|
:repos="visibleRepos"
|
||||||
:total="state.repos.length"
|
:total="state.repos.length"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
|||||||
<ul
|
<ul
|
||||||
v-else
|
v-else
|
||||||
ref="scroller"
|
ref="scroller"
|
||||||
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] lg:max-h-[calc(100vh-7rem)]"
|
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] xl:max-h-[calc(100vh-7rem)]"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="repo in visible"
|
v-for="repo in visible"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { faScrewdriverWrench, 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
|
||||||
|
size="sm"
|
||||||
|
class="font-semibold shadow-sm"
|
||||||
|
:disabled="busy(a)"
|
||||||
|
:aria-busy="busy(a)"
|
||||||
|
:data-test="`action-${a.id}`"
|
||||||
|
@click="press(a)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
:icon="busy(a) ? faSpinner : faScrewdriverWrench"
|
||||||
|
:spin="busy(a)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ a.label }}
|
||||||
|
</Button>
|
||||||
|
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
|
||||||
|
<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>
|
||||||
@@ -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',
|
||||||
@@ -155,7 +156,7 @@ describe('AttentionRow', () => {
|
|||||||
it('omits the age for an unknown timestamp', () => {
|
it('omits the age for an unknown timestamp', () => {
|
||||||
const w = mount(AttentionRow, {
|
const w = mount(AttentionRow, {
|
||||||
props: {
|
props: {
|
||||||
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }),
|
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issue_stale' }),
|
||||||
nowMs,
|
nowMs,
|
||||||
critSeconds: CRIT_SECONDS,
|
critSeconds: CRIT_SECONDS,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||||
|
|
||||||
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
|
/** 1 column below the breakpoint, 2 at or above it; follows the viewport live. */
|
||||||
export function useColumns(minWidth = '(min-width: 96rem)'): Ref<number> {
|
export function useColumns(minWidth = '(min-width: 90rem)'): Ref<number> {
|
||||||
const columns = ref(1)
|
const columns = ref(1)
|
||||||
let query: MediaQueryList | null = null
|
let query: MediaQueryList | null = null
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
|||||||
+20
-1
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const fleet = () =>
|
|||||||
}),
|
}),
|
||||||
alert({
|
alert({
|
||||||
repo: 'b/two',
|
repo: 'b/two',
|
||||||
kind: 'issues_stale',
|
kind: 'issue_stale',
|
||||||
severity: 'warn',
|
severity: 'warn',
|
||||||
subject: '4 stale issues',
|
subject: '4 stale issues',
|
||||||
url: '3',
|
url: '3',
|
||||||
@@ -62,7 +62,7 @@ describe('kindGroup', () => {
|
|||||||
['workflow_stuck', 'workflow'],
|
['workflow_stuck', 'workflow'],
|
||||||
['pr_waiting', 'pr'],
|
['pr_waiting', 'pr'],
|
||||||
['pr_checks_failing', 'pr'],
|
['pr_checks_failing', 'pr'],
|
||||||
['issues_stale', 'issue'],
|
['issue_stale', 'issue'],
|
||||||
['refresh_failed', 'data'],
|
['refresh_failed', 'data'],
|
||||||
['data_stale', 'data'],
|
['data_stale', 'data'],
|
||||||
['partial_data', 'data'],
|
['partial_data', 'data'],
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export const emptyFilters = (): Filters => ({
|
|||||||
export function kindGroup(kind: string): KindGroup {
|
export function kindGroup(kind: string): KindGroup {
|
||||||
if (kind.startsWith('workflow_')) return 'workflow'
|
if (kind.startsWith('workflow_')) return 'workflow'
|
||||||
if (kind.startsWith('pr_')) return 'pr'
|
if (kind.startsWith('pr_')) return 'pr'
|
||||||
if (kind === 'issues_stale') return 'issue'
|
if (kind.startsWith('issue_')) return 'issue'
|
||||||
return 'data'
|
return 'data'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@ export const KIND_ICON: Record<string, IconDefinition> = {
|
|||||||
workflow_stuck: faGears,
|
workflow_stuck: faGears,
|
||||||
pr_waiting: faCodePullRequest,
|
pr_waiting: faCodePullRequest,
|
||||||
pr_checks_failing: faCodePullRequest,
|
pr_checks_failing: faCodePullRequest,
|
||||||
issues_stale: faCircleDot,
|
pr_fresh: faCodePullRequest,
|
||||||
|
issue_stale: faCircleDot,
|
||||||
|
issue_fresh: faCircleDot,
|
||||||
refresh_failed: faPlugCircleXmark,
|
refresh_failed: faPlugCircleXmark,
|
||||||
data_stale: faClockRotateLeft,
|
data_stale: faClockRotateLeft,
|
||||||
partial_data: faTriangleExclamation,
|
partial_data: faTriangleExclamation,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+1
-2
@@ -13,8 +13,7 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"types": ["vite/client", "node"],
|
"types": ["vite/client", "node"],
|
||||||
"baseUrl": ".",
|
"paths": { "@/*": ["./src/*"] }
|
||||||
"paths": { "@/*": ["src/*"] }
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user