mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-12 00:45:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2469ef7d | ||
|
|
06922abe05 | ||
|
|
2100458973 | ||
|
|
6b08d09431 | ||
|
|
78a6723143 | ||
|
|
c2ce5391c0 | ||
|
|
c79ecb728f | ||
|
|
23bbbedf59 | ||
|
|
5824c4dd10 | ||
|
|
32de5d6704 | ||
|
|
3ce2c50f5a | ||
|
|
9446caaf1b | ||
|
|
8cbc495dc7 | ||
|
|
4c2c9d3341 | ||
|
|
b74396f005 | ||
|
|
e004751d88 |
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Deploy the product page to Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# One deployment at a time. Do not cancel a deployment that is running.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- '.github/workflows/pages.yaml'
|
||||
- '.github/workflows/ci.yaml'
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -14,7 +14,7 @@ Release archives and the image checksums are signed with cosign.
|
||||
|
||||
## What it shows
|
||||
|
||||
The dashboard lists only what needs attention, grouped by project:
|
||||
The dashboard lists only what needs attention, grouped by project. The project with the newest problem comes first:
|
||||
|
||||
- Failing workflows, including scheduled and other background ones, with a link to the run.
|
||||
- Pull requests that wait too long or fail their checks, with links and age.
|
||||
@@ -61,6 +61,61 @@ Alerts are evaluated when you read them, so PR ages keep advancing between refre
|
||||
- `/api/state` full state as JSON.
|
||||
- `/healthz` returns 503 until the first refresh completes.
|
||||
- `POST /refresh` runs an extra refresh and backs the Refresh button. It is on by default, limited to once per 5 minutes, and refuses cross-site requests. Set `manual_refresh: false` to disable it.
|
||||
- `POST /refresh?repo=owner/name` refreshes only that repository and answers when it finishes. An empty `repo` value runs the full refresh above.
|
||||
- The repository must be in the configured list, or the answer is 404 with a JSON `{"error": ...}` body. The match ignores case.
|
||||
- On success the answer is 200 with the same JSON as `/api/state`, plus a `repo` field. Only that repository changes. The last scan time and the schedule do not move.
|
||||
- Each repository has its own cooldown of 60 seconds. Inside it, the answer is 429 with a `Retry-After` header. A repository refresh does not use the 5-minute budget of the full refresh, and the reverse also holds.
|
||||
- While a full refresh or a refresh of the same repository runs, the answer is 409. The cooldown does not start, so you can retry at once.
|
||||
- The same cross-site refusal and `manual_refresh: false` switch apply. The GitHub rate-limit protections below apply too.
|
||||
|
||||
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
|
||||
|
||||
@@ -76,6 +131,7 @@ All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
|
||||
| `release_info`, `release_published_timestamp_seconds` | repo, version, url |
|
||||
| `alerts` | severity |
|
||||
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none |
|
||||
| `action_requests_total` (counter; result is `ok`, `rejected` or `error`, plus `auto_*` for auto actions; only with `actions`) | action, result |
|
||||
|
||||
Example alert rules:
|
||||
|
||||
|
||||
@@ -14,6 +14,25 @@ thresholds: # negative disables a check
|
||||
issue_stale_after: 720h # no activity this long = warning (aggregated per repo)
|
||||
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
|
||||
concurrency: 2
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ghtracker.raczylo.com
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="310" height="82" viewBox="0 0 310 82" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ghtracker">
|
||||
<g transform="translate(0 0)"><rect x="0" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="14" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="28" y="22" width="10" height="38" rx="1.5" fill="#ff6a5c"/><rect x="42" y="22" width="10" height="38" rx="1.5" fill="#f2b43a"/><rect x="56" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="70" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="84" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="98" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/><rect x="112" y="22" width="10" height="38" rx="1.5" fill="#4cc38a"/></g>
|
||||
<text x="140" y="56" font-family="Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif" font-size="38" font-weight="700" letter-spacing="-1" fill="#e8eaed">ghtracker</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="310" height="82" viewBox="0 0 310 82" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ghtracker">
|
||||
<g transform="translate(0 0)"><rect x="0" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="14" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="28" y="22" width="10" height="38" rx="1.5" fill="#c4291c"/><rect x="42" y="22" width="10" height="38" rx="1.5" fill="#b87400"/><rect x="56" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="70" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="84" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="98" y="22" width="10" height="38" rx="1.5" fill="#17794d"/><rect x="112" y="22" width="10" height="38" rx="1.5" fill="#17794d"/></g>
|
||||
<text x="140" y="56" font-family="Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif" font-size="38" font-weight="700" letter-spacing="-1" fill="#14171a">ghtracker</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 969 B |
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
+903
@@ -0,0 +1,903 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ghtracker: see which GitHub repositories need attention</title>
|
||||
<meta name="description" content="ghtracker scans the GitHub repositories you list through a GitHub App and shows what is failing: workflows, pull requests that wait too long, stale issues. Dashboard and Prometheus metrics in one binary. MIT licence.">
|
||||
<link rel="canonical" href="https://ghtracker.raczylo.com/">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="ghtracker">
|
||||
<meta property="og:title" content="ghtracker: see which GitHub repositories need attention">
|
||||
<meta property="og:description" content="Failing workflows, waiting pull requests and stale issues across your GitHub repositories. Prometheus metrics included. MIT licence.">
|
||||
<meta property="og:url" content="https://ghtracker.raczylo.com/">
|
||||
<meta property="og:image" content="https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png">
|
||||
<meta property="og:image:alt" content="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="ghtracker: see which GitHub repositories need attention">
|
||||
<meta name="twitter:description" content="Failing workflows, waiting pull requests and stale issues across your GitHub repositories. Prometheus metrics included.">
|
||||
<meta name="twitter:image" content="https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png">
|
||||
<link rel="icon" href="ghtracker-logo-dark.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "ghtracker",
|
||||
"description": "Dashboard and Prometheus metrics for GitHub repositories. Scans a list of repositories through a GitHub App and shows failing workflows, pull requests that wait too long and stale issues.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Linux, macOS, Windows",
|
||||
"programmingLanguage": ["Go", "TypeScript"],
|
||||
"url": "https://ghtracker.raczylo.com/",
|
||||
"downloadUrl": "https://github.com/lukaszraczylo/ghtracker/releases",
|
||||
"codeRepository": "https://github.com/lukaszraczylo/ghtracker",
|
||||
"license": "https://github.com/lukaszraczylo/ghtracker/blob/main/LICENSE",
|
||||
"isAccessibleForFree": true,
|
||||
"image": "https://ghtracker.raczylo.com/ghtracker-screenshot-dark.png",
|
||||
"author": { "@type": "Person", "name": "Lukasz Raczylo" }
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var d = document.documentElement;
|
||||
d.classList.add("js");
|
||||
try { var t = localStorage.getItem("ghtracker-theme"); if (t === "light" || t === "dark") d.setAttribute("data-theme", t); } catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0e10;
|
||||
--surface: #14161a;
|
||||
--surface-2: #1b1e23;
|
||||
--line: #2a2e35;
|
||||
--fg: #e8eaed;
|
||||
--fg-dim: #c4c9d0;
|
||||
--muted: #9aa2ad;
|
||||
--accent: #7cb7ff;
|
||||
--on-accent: #0d0e10;
|
||||
--code-str: #d6dae0;
|
||||
--sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #ffffff; --surface: #f6f7f8; --surface-2: #eceef1; --line: #d3d8de;
|
||||
--fg: #14171a; --fg-dim: #343b43; --muted: #555f6a; --accent: #0b5bd3; --on-accent: #ffffff; --code-str: #2b3138;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #ffffff; --surface: #f6f7f8; --surface-2: #eceef1; --line: #d3d8de;
|
||||
--fg: #14171a; --fg-dim: #343b43; --muted: #555f6a; --accent: #0b5bd3; --on-accent: #ffffff; --code-str: #2b3138;
|
||||
color-scheme: light;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg); font: 400 1rem/1.6 var(--sans); overflow-wrap: break-word; }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
a { color: var(--accent); text-underline-offset: 0.2em; }
|
||||
a:hover { text-decoration-thickness: 2px; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
code, kbd, pre { font-family: var(--mono); font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; }
|
||||
p code, li code, td code, dd code, th code, summary code { background: var(--surface-2); border: 1px solid var(--line); padding: 0.05em 0.35em; font-size: 0.88em; overflow-wrap: anywhere; }
|
||||
kbd { background: var(--surface-2); border: 1px solid var(--line); border-bottom-width: 2px; padding: 0.05em 0.4em; font-size: 0.85em; white-space: nowrap; }
|
||||
.wrap { width: 100%; max-width: 74rem; margin: 0 auto; padding: 0 1.25rem; }
|
||||
.skip { position: absolute; left: 0.5rem; top: -4rem; background: var(--accent); color: var(--on-accent); padding: 0.5rem 0.9rem; z-index: 100; font-weight: 600; }
|
||||
.skip:focus { top: 0.5rem; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
|
||||
/* header */
|
||||
.site-header { position: sticky; top: 0; z-index: 50; background: var(--bg); border-bottom: 1px solid var(--line); }
|
||||
.site-header .wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 0 1.25rem; }
|
||||
.brand { display: flex; align-items: center; padding: 0.4rem 0; }
|
||||
.brand img { height: 3.1rem; width: auto; }
|
||||
.logo-dark { display: block; }
|
||||
.logo-light { display: none; }
|
||||
@media (prefers-color-scheme: light) { :root:not([data-theme]) .logo-dark { display: none; } :root:not([data-theme]) .logo-light { display: block; } }
|
||||
:root[data-theme="light"] .logo-dark { display: none; }
|
||||
:root[data-theme="light"] .logo-light { display: block; }
|
||||
.head-actions { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
|
||||
.head-actions a { color: var(--fg-dim); text-decoration: none; font-size: 0.92rem; font-weight: 500; padding: 0.5rem 0.25rem; }
|
||||
.head-actions a:hover { color: var(--fg); text-decoration: underline; }
|
||||
.icon-btn { font: 500 0.85rem var(--sans); color: var(--fg-dim); background: transparent; border: 1px solid var(--line); padding: 0.4rem 0.75rem; min-height: 2.25rem; cursor: pointer; }
|
||||
.icon-btn:hover { color: var(--fg); border-color: var(--muted); }
|
||||
.primary-nav { order: 3; width: 100%; display: flex; gap: 0.25rem 1.1rem; overflow-x: auto; white-space: nowrap; padding-bottom: 0.1rem; scrollbar-width: thin; }
|
||||
.primary-nav a { color: var(--muted); text-decoration: none; font-size: 0.92rem; font-weight: 500; padding: 0.55rem 0; border-bottom: 2px solid transparent; }
|
||||
.primary-nav a:hover { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
@media (min-width: 62rem) {
|
||||
.primary-nav { order: 1; width: auto; flex: 1; padding: 0; overflow: visible; }
|
||||
.primary-nav a { padding: 1.05rem 0; }
|
||||
.head-actions { order: 2; margin-left: 0; }
|
||||
}
|
||||
|
||||
/* sections */
|
||||
section[id], .scroll-target { scroll-margin-top: 7rem; }
|
||||
.section { padding: 4rem 0; border-top: 1px solid var(--line); }
|
||||
.section-head { max-width: 46rem; margin-bottom: 2rem; }
|
||||
.eyebrow { font: 500 0.78rem var(--mono); color: var(--accent); letter-spacing: 0.06em; text-transform: uppercase; margin: 0 0 0.75rem; }
|
||||
h1, h2, h3, h4 { line-height: 1.2; margin: 0; letter-spacing: -0.01em; }
|
||||
h2 { font-size: clamp(1.7rem, 4.2vw, 2.4rem); font-weight: 650; }
|
||||
h3 { font-size: 1.3rem; font-weight: 600; }
|
||||
h4 { font-size: 1.02rem; font-weight: 600; margin: 1.5rem 0 0.5rem; }
|
||||
.section-head p:not(.eyebrow) { color: var(--muted); font-size: 1.08rem; margin: 0.9rem 0 0; }
|
||||
p { margin: 0 0 1rem; }
|
||||
ul, ol { margin: 0 0 1rem; padding-left: 1.2rem; }
|
||||
li { margin: 0.3rem 0; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 0.9rem; }
|
||||
|
||||
/* hero */
|
||||
.hero { padding: 3rem 0 3.5rem; }
|
||||
.hero-grid { display: grid; gap: 2.5rem; grid-template-columns: minmax(0, 1fr); align-items: start; }
|
||||
@media (min-width: 62rem) { .hero-grid { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 3rem; } .hero { padding: 4.5rem 0 4.5rem; } }
|
||||
h1 { font-size: clamp(2.1rem, 6.4vw, 3.4rem); font-weight: 700; letter-spacing: -0.025em; line-height: 1.08; }
|
||||
.lead { font-size: 1.12rem; color: var(--fg-dim); margin: 1.25rem 0 1.75rem; max-width: 36rem; }
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 1.5rem 0 1.75rem; }
|
||||
.btn { display: inline-block; font: 600 0.95rem var(--sans); padding: 0.7rem 1.25rem; border: 1px solid var(--accent); text-decoration: none; min-height: 2.75rem; }
|
||||
.btn-primary { background: var(--accent); color: var(--on-accent); }
|
||||
.btn-primary:hover { text-decoration: underline; }
|
||||
.btn-secondary { background: transparent; color: var(--accent); }
|
||||
.btn-secondary:hover { background: var(--surface-2); }
|
||||
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 0; border: 1px solid var(--line); max-width: 36rem; }
|
||||
.facts div { padding: 0.65rem 0.9rem; border-bottom: 1px solid var(--line); }
|
||||
.facts div:nth-child(odd) { border-right: 1px solid var(--line); }
|
||||
.facts div:nth-last-child(-n+2) { border-bottom: 0; }
|
||||
.facts dt { font: 500 0.72rem var(--mono); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.facts dd { margin: 0.15rem 0 0; font-size: 0.95rem; font-weight: 500; }
|
||||
|
||||
/* code + terminal */
|
||||
.code { position: relative; background: var(--surface); border: 1px solid var(--line); margin: 0 0 1rem; min-width: 0; }
|
||||
.code-head { font: 500 0.78rem var(--mono); color: var(--muted); padding: 0.55rem 5rem 0.55rem 1rem; border-bottom: 1px solid var(--line); background: var(--surface-2); }
|
||||
.code pre, .term pre { margin: 0; padding: 1rem; overflow-x: auto; font-size: 0.82rem; line-height: 1.6; tab-size: 2; }
|
||||
.code[data-copy] pre { padding-right: 5rem; }
|
||||
.code[data-copy] .code-head + pre { padding-right: 1rem; }
|
||||
.code code { color: var(--code-str); }
|
||||
.k { color: var(--accent); }
|
||||
.c { color: var(--muted); font-style: italic; }
|
||||
.copy { position: absolute; top: 0.45rem; right: 0.45rem; font: 500 0.75rem var(--sans); color: var(--fg-dim); background: var(--bg); border: 1px solid var(--line); padding: 0.25rem 0.6rem; min-height: 1.75rem; cursor: pointer; z-index: 2; }
|
||||
.copy:hover { color: var(--fg); border-color: var(--muted); }
|
||||
.copy.done { color: var(--accent); border-color: var(--accent); }
|
||||
.term { --t-bg: #0b0c0e; --t-fg: #d9dce1; --t-mut: #8d949e; --t-acc: #7cb7ff; background: var(--t-bg); border: 1px solid var(--line); margin: 0 0 1rem; min-width: 0; color: var(--t-fg); }
|
||||
.term-bar { display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.9rem; border-bottom: 1px solid #262a30; }
|
||||
.term-bar i { width: 0.65rem; height: 0.65rem; border-radius: 50%; background: #3a3f47; display: block; }
|
||||
.term-bar span { margin-left: 0.5rem; font: 500 0.75rem var(--mono); color: var(--t-mut); }
|
||||
.term code { color: var(--t-fg); }
|
||||
.term .h { color: var(--t-acc); font-weight: 600; }
|
||||
.term .m { color: var(--t-mut); }
|
||||
.term .a { color: var(--t-acc); }
|
||||
.term .sel { background: #23272e; display: inline-block; min-width: 100%; }
|
||||
.term .b { font-weight: 600; }
|
||||
.hero .term pre { font-size: 0.8rem; }
|
||||
.shot-frame { border: 1px solid var(--line); background: #0b0c0e; margin: 0; }
|
||||
.shot-frame img { width: 100%; }
|
||||
figcaption { font-size: 0.88rem; color: var(--muted); padding: 0.7rem 1rem; border-top: 1px solid var(--line); background: var(--surface); }
|
||||
|
||||
/* feature rows */
|
||||
.feature { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); padding: 2.75rem 0; border-top: 1px solid var(--line); align-items: start; }
|
||||
.feature:first-of-type { border-top: 0; padding-top: 0.5rem; }
|
||||
.feature .text p, .feature .text li { color: var(--fg-dim); }
|
||||
.feature .text h3 { margin-bottom: 0.9rem; }
|
||||
.feature .text .tag { font: 500 0.75rem var(--mono); color: var(--accent); margin: 0 0 0.6rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||
.feature .demo > :last-child { margin-bottom: 0; }
|
||||
.feature .demo .caption { font-size: 0.85rem; color: var(--muted); margin: -0.35rem 0 1rem; }
|
||||
@media (min-width: 62rem) {
|
||||
.feature { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
|
||||
.feature.rev .text { order: 2; }
|
||||
.feature.rev .demo { order: 1; }
|
||||
}
|
||||
|
||||
/* tables */
|
||||
.tablewrap { overflow-x: auto; border: 1px solid var(--line); margin: 0 0 1rem; background: var(--bg); }
|
||||
table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.93rem; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 0.65rem 0.9rem; border-bottom: 1px solid var(--line); }
|
||||
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
|
||||
thead th { background: var(--surface); font-weight: 600; white-space: nowrap; position: relative; }
|
||||
tbody th { font-weight: 500; background: var(--bg); }
|
||||
.tablewrap.wrap-cells td, .tablewrap.wrap-cells th { white-space: normal; }
|
||||
.cmp { min-width: 54rem; }
|
||||
.cmp thead th:first-child, .cmp tbody th { position: sticky; left: 0; z-index: 1; border-right: 1px solid var(--line); min-width: 11.5rem; max-width: 14rem; }
|
||||
.cmp thead th:first-child { z-index: 2; background: var(--surface); }
|
||||
.cmp td { min-width: 9.5rem; }
|
||||
.cmp thead th.us, .cmp td.us { background: var(--surface); }
|
||||
.y { color: var(--accent); font-weight: 600; }
|
||||
.p { color: var(--fg); font-weight: 500; }
|
||||
.nd { color: var(--muted); font-style: italic; }
|
||||
.no { color: var(--fg); }
|
||||
.cell-note { display: block; font-size: 0.78rem; color: var(--muted); font-weight: 400; font-style: normal; margin-top: 0.15rem; }
|
||||
.cmp-meta { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); margin-top: 2rem; }
|
||||
@media (min-width: 52rem) { .cmp-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.note { border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface); padding: 0.8rem 1rem; margin: 0 0 1.5rem; font-size: 0.95rem; }
|
||||
.note p:last-child { margin-bottom: 0; }
|
||||
|
||||
/* install tabs */
|
||||
.tablist { display: flex; flex-wrap: wrap; gap: 0; border-bottom: 1px solid var(--line); margin-bottom: 1.5rem; overflow-x: auto; }
|
||||
.tablist button { font: 500 0.92rem var(--sans); color: var(--muted); background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 0.75rem 1.1rem; margin-bottom: -1px; cursor: pointer; white-space: nowrap; min-height: 2.75rem; }
|
||||
.tablist button:hover { color: var(--fg); }
|
||||
.tablist button[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
.tabpanel { margin-bottom: 2rem; }
|
||||
.js .tabpanel { margin-bottom: 0; }
|
||||
.js .tabpanel > h3 { display: none; }
|
||||
.tabpanel > h3 { font-size: 1.1rem; margin-bottom: 0.75rem; }
|
||||
.tabpanel p { color: var(--fg-dim); max-width: 46rem; }
|
||||
.tabpanel .code, .tabpanel .term { max-width: 52rem; }
|
||||
|
||||
/* misc layouts */
|
||||
.two { display: grid; gap: 1.5rem 3rem; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 52rem) { .two { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.steps { counter-reset: s; list-style: none; padding: 0; margin: 0 0 1.5rem; }
|
||||
.steps li { counter-increment: s; position: relative; padding: 0.15rem 0 0.9rem 2.75rem; color: var(--fg-dim); }
|
||||
.steps li::before { content: counter(s); position: absolute; left: 0; top: 0.1rem; width: 1.9rem; height: 1.9rem; border: 1px solid var(--line); font: 500 0.82rem/1.8rem var(--mono); text-align: center; color: var(--accent); background: var(--surface); }
|
||||
details { border-top: 1px solid var(--line); padding: 0.9rem 0; }
|
||||
details:last-of-type { border-bottom: 1px solid var(--line); }
|
||||
summary { cursor: pointer; font-weight: 600; list-style: none; padding-right: 1.5rem; position: relative; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::after { content: "+"; position: absolute; right: 0; top: 0; font-family: var(--mono); color: var(--accent); }
|
||||
details[open] summary::after { content: "\2212"; }
|
||||
details p, details ul { color: var(--fg-dim); margin-top: 0.75rem; max-width: 46rem; }
|
||||
.status-dot { font-family: var(--mono); white-space: nowrap; }
|
||||
.inline-list { list-style: none; padding: 0; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: 0.4rem 0.5rem; }
|
||||
.inline-list li { margin: 0; }
|
||||
.inline-list code { display: inline-block; }
|
||||
|
||||
/* footer */
|
||||
.site-footer { border-top: 1px solid var(--line); padding: 2.5rem 0 3rem; color: var(--muted); font-size: 0.92rem; }
|
||||
.foot-grid { display: grid; gap: 1.75rem 3rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@media (min-width: 52rem) { .foot-grid { grid-template-columns: 2fr 1fr 1fr 1fr; } }
|
||||
.site-footer h2 { font: 600 0.8rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg); margin: 0 0 0.75rem; }
|
||||
.site-footer ul { list-style: none; padding: 0; margin: 0; }
|
||||
.site-footer li { margin: 0.35rem 0; }
|
||||
.site-footer a { color: var(--fg-dim); }
|
||||
.foot-brand { grid-column: 1 / -1; }
|
||||
@media (min-width: 52rem) { .foot-brand { grid-column: auto; } }
|
||||
.foot-bottom { border-top: 1px solid var(--line); margin-top: 2rem; padding-top: 1.25rem; }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
||||
@media (max-width: 40rem) {
|
||||
.section { padding: 3rem 0; }
|
||||
.feature { padding: 2rem 0; }
|
||||
.code pre, .term pre { font-size: 0.78rem; }
|
||||
}
|
||||
|
||||
/* ghtracker additions */
|
||||
.shot { margin: 0; border: 1px solid var(--line); background: var(--surface); }
|
||||
.shot picture, .shot img { display: block; width: 100%; }
|
||||
.numbers { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); border: 1px solid var(--line); margin: 0 0 1rem; }
|
||||
.numbers div { padding: 0.9rem 1rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: -1px 0 0 -1px; }
|
||||
.numbers dt { font: 500 0.78rem var(--mono); color: var(--muted); }
|
||||
.numbers dd { margin: 0.25rem 0 0; font-size: 1.05rem; font-weight: 600; }
|
||||
.sev-crit { color: #ff6a5c; font-weight: 600; } .sev-warn { color: #f2b43a; font-weight: 600; } .sev-ok { color: #4cc38a; font-weight: 600; }
|
||||
:root[data-theme="light"] .sev-crit { color: #c4291c; } :root[data-theme="light"] .sev-warn { color: #8f5b00; } :root[data-theme="light"] .sev-ok { color: #17794d; }
|
||||
@media (prefers-color-scheme: light) { :root:not([data-theme]) .sev-crit { color: #c4291c; } :root:not([data-theme]) .sev-warn { color: #8f5b00; } :root:not([data-theme]) .sev-ok { color: #17794d; } }
|
||||
</style></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap">
|
||||
<a class="brand" href="#top" aria-label="ghtracker home">
|
||||
<img class="logo-dark" src="ghtracker-logo-dark.svg" alt="ghtracker" width="310" height="82">
|
||||
<img class="logo-light" src="ghtracker-logo-light.svg" alt="" width="310" height="82">
|
||||
</a>
|
||||
<div class="head-actions">
|
||||
<a href="https://github.com/lukaszraczylo/ghtracker">GitHub</a>
|
||||
<button type="button" class="icon-btn" id="theme-toggle" hidden aria-label="Switch colour theme">Theme</button>
|
||||
</div>
|
||||
<nav class="primary-nav" aria-label="Sections">
|
||||
<a href="#interface">Dashboard</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#alerts">Alerts</a>
|
||||
<a href="#install">Install</a>
|
||||
<a href="#setup">Set up</a>
|
||||
<a href="#metrics">Metrics</a>
|
||||
<a href="#reference">Reference</a>
|
||||
<a href="#troubleshooting">Troubleshooting</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- hero -->
|
||||
<section class="hero" id="top">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">GitHub repository dashboard</p>
|
||||
<h1>See which of your GitHub repositories need attention</h1>
|
||||
<p class="lead">ghtracker scans the repositories you list through a GitHub App. It shows only what is wrong: failing workflows, pull requests that wait too long and stale issues. One binary serves the dashboard and Prometheus metrics.</p>
|
||||
<div class="code" data-copy><div class="code-head">Docker</div><pre tabindex="0"><code>docker run <span class="k">--rm</span> <span class="k">-p</span> 8080:8080 \
|
||||
<span class="k">-v</span> "$PWD/config.yaml:/config/config.yaml:ro" \
|
||||
<span class="k">-e</span> GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
|
||||
ghcr.io/lukaszraczylo/ghtracker:latest</code></pre></div>
|
||||
<p class="small muted">Binaries, Kubernetes and building from source: <a href="#install">Install</a>. You need a GitHub App first: <a href="#setup">Set up</a>.</p>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn-primary" href="https://github.com/lukaszraczylo/ghtracker">View on GitHub</a>
|
||||
<a class="btn btn-secondary" href="#reference">Documentation</a>
|
||||
</div>
|
||||
<dl class="facts">
|
||||
<div><dt>Licence</dt><dd>MIT</dd></div>
|
||||
<div><dt>Language</dt><dd>Go and Vue</dd></div>
|
||||
<div><dt>Platforms</dt><dd>Linux, macOS, Windows</dd></div>
|
||||
<div><dt>Container image</dt><dd>amd64, arm64</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<figure class="shot">
|
||||
<picture>
|
||||
<source srcset="ghtracker-hero-light.png" media="(prefers-color-scheme: light)">
|
||||
<img src="ghtracker-hero-dark.png" width="760" height="860" alt="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository">
|
||||
</picture>
|
||||
</figure>
|
||||
<p class="small muted">Example data: 20 public repositories of the author, scanned in October 2026.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- dashboard -->
|
||||
<section class="section" id="interface" aria-labelledby="interface-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Dashboard</p>
|
||||
<h2 id="interface-h">Healthy repositories stay out of the way</h2>
|
||||
<p>A long list of green items hides the red ones. ghtracker lists only items that need attention, grouped by repository, and keeps one line per repository in a side panel for everything else.</p>
|
||||
</div>
|
||||
<figure class="shot">
|
||||
<picture>
|
||||
<source srcset="ghtracker-screenshot-light.png" media="(prefers-color-scheme: light)">
|
||||
<img src="ghtracker-screenshot-dark.png" width="1440" height="1180" alt="ghtracker dashboard: 10 critical alerts and 1 warning across 20 projects, a health strip with one segment per project, and failing workflows and pull requests grouped by repository" loading="lazy">
|
||||
</picture>
|
||||
<figcaption>The same page at desktop width, with 20 public repositories of the author.</figcaption>
|
||||
</figure>
|
||||
<ol class="steps" style="margin-top:2rem">
|
||||
<li><strong>Headline.</strong> The count of critical items and warnings, and how many projects they come from.</li>
|
||||
<li><strong>Health strip.</strong> One segment per project, red, amber or green. Select a segment to focus that project. Segments that do not match the current search or filters dim.</li>
|
||||
<li><strong>Five figures.</strong> Workflows failing, pull requests waiting, pull requests with failing checks, stale issues and data problems.</li>
|
||||
<li><strong>Needs attention.</strong> One block per repository with its latest release. Each row links to the workflow run, pull request or issue, and shows its age. A meter beside a waiting pull request fills towards the critical age.</li>
|
||||
<li><strong>Projects.</strong> Every project with its latest release and age, and counts of workflows, pull requests and issues, each linked to GitHub.</li>
|
||||
</ol>
|
||||
<p class="muted">The page uses the full width, works on phones and follows the colour scheme of the system. While the first scan runs it shows how many repositories are done.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- features -->
|
||||
<section class="section" id="features" aria-labelledby="features-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Features</p>
|
||||
<h2 id="features-h">What ghtracker does</h2>
|
||||
<p>Every snippet below comes from the repository: the source, <code>config.example.yaml</code> or the Kubernetes example.</p>
|
||||
</div>
|
||||
|
||||
<article class="feature">
|
||||
<div class="text">
|
||||
<p class="tag">Workflows</p>
|
||||
<h3>Failing workflows, including background ones</h3>
|
||||
<p>For every active workflow ghtracker takes the newest run on the default branch. A failed run is critical. Scheduled, dynamic and manually started workflows carry a <code>background</code> mark, because nobody is watching them run.</p>
|
||||
<p>Pull request runs appear as checks on the pull request instead. A workflow that has not run for 30 days is muted, so an old failure does not raise an alert for ever.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code"><div class="code-head">Example rows</div><pre tabindex="0"><code><span class="k">critical</span> lukaszraczylo/git-velocity
|
||||
Git Velocity Analysis (background)
|
||||
failure on main (schedule) 4d
|
||||
|
||||
<span class="k">critical</span> lukaszraczylo/gohoarder
|
||||
Release
|
||||
failure on main (push) 29h</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature rev">
|
||||
<div class="text">
|
||||
<p class="tag">Pull requests</p>
|
||||
<h3>Pull requests that wait too long</h3>
|
||||
<p>A non-draft pull request is a warning after 48 hours and critical after 7 days. Drafts are ignored. A pull request with failing checks is a warning, and its check names appear in the row.</p>
|
||||
<p>Set the limits with <code>thresholds</code>. A negative value turns a check off.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">config.yaml</div><pre tabindex="0"><code><span class="k">thresholds</span>:
|
||||
pr_warn_after: 48h
|
||||
pr_crit_after: 168h
|
||||
issue_stale_after: 720h
|
||||
workflow_stuck_after: 6h
|
||||
workflow_dormant_after: 720h</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="text">
|
||||
<p class="tag">Search and filters</p>
|
||||
<h3>Search, and filters that show their own counts</h3>
|
||||
<p>Type to match projects, versions, titles and details. Every word must match. Press <kbd>/</kbd> to jump to the search box and <kbd>Esc</kbd> to clear it.</p>
|
||||
<p>Filter by severity, kind (workflows, pull requests, issues, data) and owner. Each chip shows how many items it would leave given the other filters, and a chip that would match nothing is disabled. The address holds the filters, so you can share a view.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">Address of a filtered view</div><pre tabindex="0"><code>https://ghtracker.example.com/?q=bump&kind=pr&owner=lukaszraczylo&sev=crit</code></pre></div>
|
||||
<p class="caption">Search for "bump", pull requests only, one owner, critical only.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature rev">
|
||||
<div class="text">
|
||||
<p class="tag">Releases</p>
|
||||
<h3>Latest release of every project, one click away</h3>
|
||||
<p>Each project shows its newest GitHub release with a link and its age. A project without a release shows its newest tag. The same data is available as the metric <code>ghtracker_release_info</code>.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code"><div class="code-head">Example series</div><pre tabindex="0"><code>ghtracker_release_info{<span class="k">repo</span>="lukaszraczylo/kportal",
|
||||
<span class="k">version</span>="v0.11.2",
|
||||
<span class="k">url</span>="https://github.com/lukaszraczylo/kportal/releases/tag/v0.11.2"} 1
|
||||
ghtracker_release_published_timestamp_seconds{<span class="k">repo</span>="lukaszraczylo/kportal"} 1.790906487e+09</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="text">
|
||||
<p class="tag">Metrics</p>
|
||||
<h3>Prometheus metrics for alerting</h3>
|
||||
<p>Metrics are computed when you scrape, so a pull request age keeps growing between scans. Per-repository gauges cover health, open pull requests and issues, workflow state and the age of the oldest pull request.</p>
|
||||
<p>The Kubernetes example ships alert rules for failing workflows, pull requests waiting over 3 days, repositories that fail to scan, stale scans and a low GitHub API budget.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">Prometheus rules</div><pre tabindex="0"><code>- alert: GhtrackerWorkflowFailing
|
||||
expr: ghtracker_workflow_failing == 1
|
||||
for: 10m
|
||||
- alert: GhtrackerPRWaiting
|
||||
expr: ghtracker_pr_age_seconds > 3 * 86400
|
||||
- alert: GhtrackerStale
|
||||
expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature rev">
|
||||
<div class="text">
|
||||
<p class="tag">GitHub API</p>
|
||||
<h3>Careful with your rate limit</h3>
|
||||
<ul>
|
||||
<li>Conditional requests with <code>ETag</code>. A <code>304</code> reply does not count against the primary limit.</li>
|
||||
<li>Requests stop while fewer than 100 remain, until the window resets.</li>
|
||||
<li>On a primary or secondary limit response ghtracker waits for <code>Retry-After</code> or the reset time, stops the scan and keeps the last good data.</li>
|
||||
<li>Two repositories at a time by default. Pagination stops at <code>max_items</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code"><div class="code-head">Cost of one scan</div><pre tabindex="0"><code>requests per scan =
|
||||
6 per repository
|
||||
+ 2 per open pull request
|
||||
+ 1 per active workflow
|
||||
|
||||
63 repositories take about 100 seconds
|
||||
and a few hundred requests</code></pre></div>
|
||||
<p class="caption">Measured on 63 repositories with the default settings. The installation limit of a GitHub App is at least 5,000 requests per hour.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="text">
|
||||
<p class="tag">Accuracy</p>
|
||||
<h3>A failure is confirmed before it alerts</h3>
|
||||
<p>GitHub sometimes answers a workflow run listing from a lagging replica. One answer held 135 runs ending three weeks ago while the current listing held 414. A client that reuses its connection can keep hitting that replica.</p>
|
||||
<p>ghtracker asks again on a new connection, without its response cache, for every failing workflow. It keeps the union of both answers, so the newest run decides.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code"><div class="code-head">Example from a real repository</div><pre tabindex="0"><code>stale answer runs: 135 newest: 09-11 failure
|
||||
current answer runs: 414 newest: 10-02 success
|
||||
|
||||
shown after the check: success</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature rev">
|
||||
<div class="text">
|
||||
<p class="tag">On demand</p>
|
||||
<h3>Rescan with one button</h3>
|
||||
<p>The <strong>Rescan repositories</strong> button starts a new scan and follows it, so the new data appears as soon as the scan ends. One rescan every 5 minutes is allowed, and the server refuses requests that come from other sites. Set <code>manual_refresh: false</code> to turn the button off.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">Same thing from a shell</div><pre tabindex="0"><code>curl -X POST http://localhost:8080/refresh</code></pre></div>
|
||||
<p class="caption">Answers <code>202</code>. A second call within 5 minutes answers <code>429</code>.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature">
|
||||
<div class="text">
|
||||
<p class="tag">One binary</p>
|
||||
<h3>The dashboard is inside the binary</h3>
|
||||
<p>The Vue app is built into the Go binary, so there is nothing else to host. The container image is a static binary on a distroless base that runs as a non-root user. Release checksums and image manifests are signed with cosign.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">Command line</div><pre tabindex="0"><code>ghtracker -version
|
||||
ghtracker -config config.yaml</code></pre></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="feature rev">
|
||||
<div class="text">
|
||||
<p class="tag">Kubernetes</p>
|
||||
<h3>Example manifests for a cluster</h3>
|
||||
<p>The repository has a Kustomize base with a Deployment, a Service, a ConfigMap, an External Secrets resource for the private key, a Traefik route, a ServiceMonitor and a PrometheusRule. Keep one replica, because each replica scans GitHub on its own.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="code" data-copy><div class="code-head">Review before you apply</div><pre tabindex="0"><code>kustomize build deploy/kubernetes | kubectl diff -f -</code></pre></div>
|
||||
<p class="caption">The dashboard has no login of its own. Put it behind your ingress authentication.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- alerts -->
|
||||
<section class="section" id="alerts" aria-labelledby="alerts-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Alerts</p>
|
||||
<h2 id="alerts-h">How ghtracker decides what is wrong</h2>
|
||||
<p>Alerts are evaluated every time you read the page or scrape the metrics, so ages keep advancing between scans. Draft pull requests and archived repositories raise no work alerts.</p>
|
||||
</div>
|
||||
<div class="tablewrap wrap-cells">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Condition</th><th scope="col">Severity</th><th scope="col">Setting</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>The newest completed default-branch run of a workflow failed</td><td><span class="sev-crit">critical</span></td><td><code>thresholds.workflow_dormant_after</code> mutes workflows idle longer than this</td></tr>
|
||||
<tr><td>A scan of a repository failed. The dashboard keeps the last good data.</td><td><span class="sev-crit">critical</span></td><td></td></tr>
|
||||
<tr><td>A non-draft pull request is older than the critical age</td><td><span class="sev-crit">critical</span></td><td><code>thresholds.pr_crit_after</code>, default 168h</td></tr>
|
||||
<tr><td>A non-draft pull request is older than the warning age</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.pr_warn_after</code>, default 48h</td></tr>
|
||||
<tr><td>The checks of a pull request fail</td><td><span class="sev-warn">warning</span></td><td></td></tr>
|
||||
<tr><td>A workflow is queued or running for too long</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.workflow_stuck_after</code>, default 6h</td></tr>
|
||||
<tr><td>Open issues without activity. One alert for each repository.</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.issue_stale_after</code>, default 720h</td></tr>
|
||||
<tr><td>A section failed to load, for example a missing App permission</td><td><span class="sev-warn">warning</span></td><td></td></tr>
|
||||
<tr><td>The data of a repository is older than expected</td><td><span class="sev-warn">warning</span></td><td><code>thresholds.data_stale_after</code>, default twice the interval plus 30 minutes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted small">Durations use Go syntax such as <code>48h</code>. There is no <code>d</code> unit.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- install -->
|
||||
<section class="section" id="install" aria-labelledby="install-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Install</p>
|
||||
<h2 id="install-h">Install ghtracker</h2>
|
||||
<p>Pick one method. Release archives cover Linux, macOS and Windows on amd64 and arm64.</p>
|
||||
</div>
|
||||
|
||||
<div data-tabs>
|
||||
<div class="tabpanel" id="tab-docker" data-tab-title="Docker">
|
||||
<h3>Docker</h3>
|
||||
<div class="code" data-copy><pre tabindex="0"><code>docker run <span class="k">--rm</span> <span class="k">-p</span> 8080:8080 \
|
||||
<span class="k">-v</span> "$PWD/config.yaml:/config/config.yaml:ro" \
|
||||
<span class="k">-e</span> GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
|
||||
ghcr.io/lukaszraczylo/ghtracker:latest</code></pre></div>
|
||||
<p>The config must set <code>listen: ":8080"</code> so the port is reachable from outside the container. Tags are the release version, for example <code>0.2.3</code>, and <code>latest</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabpanel" id="tab-binary" data-tab-title="Binary">
|
||||
<h3>Release archive</h3>
|
||||
<p>The <a href="https://github.com/lukaszraczylo/ghtracker/releases">releases page</a> has one archive per platform, named <code>ghtracker-<os>-<arch>.tar.gz</code> (<code>.zip</code> on Windows). Each holds the binary, the licence, the README and <code>config.example.yaml</code>.</p>
|
||||
<div class="code" data-copy><pre tabindex="0"><code>tar -xzf ghtracker-linux-amd64.tar.gz
|
||||
./ghtracker -config config.yaml</code></pre></div>
|
||||
</div>
|
||||
|
||||
<div class="tabpanel" id="tab-k8s" data-tab-title="Kubernetes">
|
||||
<h3>Kubernetes</h3>
|
||||
<p>Copy <code>deploy/kubernetes/</code> from the repository, set your App ID, repositories, secret store and host, then review the result before you apply it.</p>
|
||||
<div class="code" data-copy><pre tabindex="0"><code>git clone https://github.com/lukaszraczylo/ghtracker.git
|
||||
kustomize build ghtracker/deploy/kubernetes | kubectl diff -f -</code></pre></div>
|
||||
<p>If your cluster uses GitOps, commit the manifests and let your controller apply them.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabpanel" id="tab-source" data-tab-title="Build from source">
|
||||
<h3>Build from source</h3>
|
||||
<div class="code" data-copy><pre tabindex="0"><code>git clone https://github.com/lukaszraczylo/ghtracker.git
|
||||
cd ghtracker
|
||||
make build</code></pre></div>
|
||||
<p>The build needs Go 1.25 or later, Node 22.13 or later and pnpm 11. <code>make build</code> installs the UI packages, builds the UI into <code>dist/</code> and then builds the Go binary, which embeds <code>dist/</code>. Without the UI build the server answers <code>503</code> at <code>/</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabpanel" id="tab-verify" data-tab-title="Verify signatures">
|
||||
<h3>Verify a release signature</h3>
|
||||
<p>Release checksums are signed with <a href="https://github.com/sigstore/cosign">cosign</a> using keyless signing. Download the checksums file and its sigstore bundle from the release, then run:</p>
|
||||
<div class="code" data-copy><pre tabindex="0"><code>cosign verify-blob \
|
||||
<span class="k">--certificate-identity-regexp</span> "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \
|
||||
<span class="k">--certificate-oidc-issuer</span> "https://token.actions.githubusercontent.com" \
|
||||
<span class="k">--bundle</span> ghtracker-checksums.txt.sigstore.json \
|
||||
ghtracker-checksums.txt</code></pre></div>
|
||||
<p>Then check the archive against the verified checksums file with <code>shasum -a 256</code> or <code>sha256sum</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- set up -->
|
||||
<section class="section" id="setup" aria-labelledby="setup-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Set up</p>
|
||||
<h2 id="setup-h">From install to the first dashboard</h2>
|
||||
<p>ghtracker signs in as a GitHub App. An App can only read what you grant it and you can install it on a user account or an organisation.</p>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li>Create a GitHub App with <strong>read-only</strong> repository permissions: Metadata, Contents, Issues, Pull requests, Actions, Checks and Commit statuses. It needs no webhook and no write access.</li>
|
||||
<li>Install the App on every account or organisation whose repositories you list. Choose all repositories, or select the ones you want.</li>
|
||||
<li>Generate a private key for the App and keep the <code>.pem</code> file safe.</li>
|
||||
<li>Copy <code>config.example.yaml</code> to <code>config.yaml</code>, set <code>github.app_id</code> and list your repositories.</li>
|
||||
<li>Give ghtracker the key through <code>github.private_key_file</code> or the environment variable <code>GHTRACKER_PRIVATE_KEY</code>, then start it and open <code>http://localhost:8080</code>.</li>
|
||||
</ol>
|
||||
<div class="code" data-copy><div class="code-head">config.yaml</div><pre tabindex="0"><code><span class="k">listen</span>: ":8080"
|
||||
<span class="k">refresh_interval</span>: 6h
|
||||
<span class="k">github</span>:
|
||||
<span class="k">app_id</span>: 123456
|
||||
<span class="k">private_key_file</span>: /secrets/ghtracker.pem
|
||||
<span class="k">installation_id</span>: 0 <span class="c"># 0 = find the installation for each repository</span>
|
||||
<span class="k">repos</span>:
|
||||
- lukaszraczylo/kubemirror
|
||||
- lukaszraczylo/semver-generator</code></pre></div>
|
||||
<div class="note">
|
||||
<p><strong>Public and private repositories.</strong> GitHub lets any installation read public data, so public repositories work even with fewer permissions. Private repositories need every permission above, and each installation must accept the update after you add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- metrics -->
|
||||
<section class="section" id="metrics" aria-labelledby="metrics-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Metrics</p>
|
||||
<h2 id="metrics-h">Prometheus metrics</h2>
|
||||
<p>All metrics use the <code>ghtracker_</code> prefix and are computed at scrape time. A scrape interval of one minute is enough. Reading the metrics costs no GitHub requests.</p>
|
||||
</div>
|
||||
<div class="tablewrap wrap-cells">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Metric</th><th scope="col">Labels</th><th scope="col">Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>repo_up</code>, <code>repo_health</code></td><td>repo</td><td>Last scan succeeded. Worst alert: 0 ok, 1 warning, 2 critical.</td></tr>
|
||||
<tr><td><code>repo_open_issues</code>, <code>repo_open_prs</code></td><td>repo</td><td>Open issues and open pull requests, including drafts.</td></tr>
|
||||
<tr><td><code>repo_open_prs_failing_checks</code>, <code>repo_stale_issues</code></td><td>repo</td><td>Pull requests with failing checks. Issues without recent activity.</td></tr>
|
||||
<tr><td><code>repo_oldest_pr_age_seconds</code></td><td>repo</td><td>Age of the oldest non-draft pull request.</td></tr>
|
||||
<tr><td><code>pr_age_seconds</code>, <code>pr_checks_failing</code></td><td>repo, number</td><td>One series per non-draft pull request.</td></tr>
|
||||
<tr><td><code>workflow_failing</code>, <code>workflow_running</code></td><td>repo, workflow, background</td><td>The newest completed run failed. A run is in progress.</td></tr>
|
||||
<tr><td><code>workflow_last_run_timestamp_seconds</code></td><td>repo, workflow, background</td><td>When the newest default-branch run started.</td></tr>
|
||||
<tr><td><code>release_info</code></td><td>repo, version, url</td><td>Latest release or tag. The value is always 1.</td></tr>
|
||||
<tr><td><code>release_published_timestamp_seconds</code></td><td>repo</td><td>When the latest release was published.</td></tr>
|
||||
<tr><td><code>alerts</code></td><td>severity</td><td>Active alerts of each severity.</td></tr>
|
||||
<tr><td><code>refreshes_total</code>, <code>repo_refresh_errors_total</code></td><td></td><td>Completed scans. Scan failures of single repositories.</td></tr>
|
||||
<tr><td><code>last_refresh_timestamp_seconds</code>, <code>last_refresh_duration_seconds</code></td><td></td><td>When the last scan started and how long it took.</td></tr>
|
||||
<tr><td><code>github_rate_limit_remaining</code></td><td></td><td>Requests left in the current GitHub window.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- reference -->
|
||||
<section class="section" id="reference" aria-labelledby="reference-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Reference</p>
|
||||
<h2 id="reference-h">Reference</h2>
|
||||
</div>
|
||||
|
||||
<h3 id="cli">Command line and environment</h3>
|
||||
<div class="tablewrap wrap-cells">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Effect</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>-config <path></code></td><td>Path of the YAML config file. Default <code>config.yaml</code>.</td></tr>
|
||||
<tr><td><code>-version</code></td><td>Print the version and exit.</td></tr>
|
||||
<tr><td><code>GHTRACKER_PRIVATE_KEY</code></td><td>The App private key as PEM text. It takes precedence over <code>github.private_key_file</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 id="config-ref" style="margin-top:2.5rem">Configuration keys</h3>
|
||||
<div class="tablewrap wrap-cells">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Key</th><th scope="col">Default</th><th scope="col">Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>listen</code></td><td><code>:8080</code></td><td>Address of the HTTP server.</td></tr>
|
||||
<tr><td><code>refresh_interval</code></td><td><code>6h</code></td><td>Time between scans. The minimum is <code>1m</code>.</td></tr>
|
||||
<tr><td><code>manual_refresh</code></td><td><code>true</code></td><td>The rescan button and <code>POST /refresh</code>.</td></tr>
|
||||
<tr><td><code>github.app_id</code></td><td></td><td>Required. The numeric ID of the GitHub App.</td></tr>
|
||||
<tr><td><code>github.private_key_file</code></td><td></td><td>Path of the private key. Required unless <code>GHTRACKER_PRIVATE_KEY</code> is set.</td></tr>
|
||||
<tr><td><code>github.installation_id</code></td><td><code>0</code></td><td>Use one installation for every repository, or <code>0</code> to find the installation of each repository.</td></tr>
|
||||
<tr><td><code>github.api_url</code></td><td><code>https://api.github.com</code></td><td>API address. GitHub Enterprise Server has not been tested.</td></tr>
|
||||
<tr><td><code>thresholds.*</code></td><td>see <a href="#alerts">Alerts</a></td><td>Ages and durations that raise alerts. A negative value disables a check.</td></tr>
|
||||
<tr><td><code>max_items</code></td><td><code>100</code></td><td>Most issues and pull requests read for each repository.</td></tr>
|
||||
<tr><td><code>concurrency</code></td><td><code>2</code></td><td>Repositories scanned at the same time.</td></tr>
|
||||
<tr><td><code>repos</code></td><td></td><td>Required. A list of <code>owner/name</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 id="endpoints" style="margin-top:2.5rem">HTTP endpoints</h3>
|
||||
<div class="tablewrap wrap-cells">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Path</th><th scope="col">Answer</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>/</code></td><td>The dashboard.</td></tr>
|
||||
<tr><td><code>/api/state</code></td><td>The state as JSON: repositories, alerts, thresholds and scan progress.</td></tr>
|
||||
<tr><td><code>/healthz</code></td><td><code>200</code> after the first scan finished, <code>503</code> before.</td></tr>
|
||||
<tr><td><code>/metrics</code></td><td>Prometheus metrics.</td></tr>
|
||||
<tr><td><code>POST /refresh</code></td><td>Starts a scan. Allowed once every 5 minutes. Same-site requests only.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="muted small">The dashboard keeps its data in memory. After a restart it scans again, and the page shows the progress.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- troubleshooting -->
|
||||
<section class="section" id="troubleshooting" aria-labelledby="trouble-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">Troubleshooting</p>
|
||||
<h2 id="trouble-h">Common failures</h2>
|
||||
</div>
|
||||
<div class="two">
|
||||
<div>
|
||||
<h3>HTTP 403: Resource not accessible by integration</h3>
|
||||
<p>The row says <strong>Incomplete data</strong> for a private repository. The App lacks a permission. Grant read access to Issues, Actions, Checks and Commit statuses as well as the others in <a href="#setup">Set up</a>, then accept the update on every installation. Restart ghtracker, because installation tokens last an hour.</p>
|
||||
<h3 style="margin-top:2rem">github rate limit reached</h3>
|
||||
<p>ghtracker stopped the scan on purpose and keeps the last good data. It tries again at the next scan. Lower the number of repositories, raise <code>refresh_interval</code> or lower <code>concurrency</code>. The metric <code>ghtracker_github_rate_limit_remaining</code> shows the budget.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>The page says "Scanning repositories" for a long time</h3>
|
||||
<p>The first scan has to read every repository. Sixty repositories take about two minutes. The page shows how many are done and the container is ready before the scan ends.</p>
|
||||
<h3 style="margin-top:2rem">UI is not built</h3>
|
||||
<p>The server answers <code>503</code> at <code>/</code> when you built the binary without the UI. Run <code>make build</code>, or <code>pnpm build</code> before <code>go build</code>.</p>
|
||||
<h3 style="margin-top:2rem">Config errors at start</h3>
|
||||
<p>ghtracker names the problem and exits, for example <code>repos must list at least one owner/name</code>, <code>github.app_id is required</code> or <code>refresh_interval 30s is below the 1m0s minimum</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- faq -->
|
||||
<section class="section" id="faq" aria-labelledby="faq-h">
|
||||
<div class="wrap">
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">FAQ</p>
|
||||
<h2 id="faq-h">Questions and answers</h2>
|
||||
</div>
|
||||
<details><summary>Does ghtracker change anything on GitHub?</summary><p>No. The client sends only <code>GET</code> requests, apart from the request that exchanges the App key for an installation token. Give the App read-only permissions.</p></details>
|
||||
<details><summary>Does it need webhooks?</summary><p>No. It polls on a schedule, so it works behind a firewall and needs no public address.</p></details>
|
||||
<details><summary>Why every six hours?</summary><p>It is the default and keeps the cost of a scan low. Set <code>refresh_interval</code> down to one minute, or use the rescan button when you need fresh data.</p></details>
|
||||
<details><summary>Can I run more than one replica?</summary><p>It is not useful. Each replica scans GitHub on its own and spends API budget, and the data lives in memory.</p></details>
|
||||
<details><summary>Is there a login?</summary><p>No. Put the dashboard behind a reverse proxy or an identity provider.</p></details>
|
||||
<details><summary>Which repositories can it read?</summary><p>Any repository that an installation of your App can access, public or private. A repository outside every installation fails its scan and shows as critical.</p></details>
|
||||
<details><summary>What is the licence?</summary><p>MIT.</p></details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<p style="color:var(--fg);font-weight:600;margin-bottom:0.5rem">ghtracker</p>
|
||||
<p>GitHub repository dashboard and Prometheus metrics. MIT licence.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Project</h2>
|
||||
<ul>
|
||||
<li><a href="https://github.com/lukaszraczylo/ghtracker">GitHub</a></li>
|
||||
<li><a href="https://github.com/lukaszraczylo/ghtracker/releases">Releases</a></li>
|
||||
<li><a href="https://github.com/lukaszraczylo/ghtracker/issues">Issues</a></li>
|
||||
<li><a href="https://github.com/lukaszraczylo/ghtracker/pkgs/container/ghtracker">Container image</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Docs</h2>
|
||||
<ul>
|
||||
<li><a href="#setup">Set up</a></li>
|
||||
<li><a href="#alerts">Alerts</a></li>
|
||||
<li><a href="#reference">Reference</a></li>
|
||||
<li><a href="#troubleshooting">Troubleshooting</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Legal</h2>
|
||||
<ul>
|
||||
<li><a href="https://github.com/lukaszraczylo/ghtracker/blob/main/LICENSE">MIT licence</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-bottom">
|
||||
<p>Made by Lukasz Raczylo. Built with <a href="https://go.dev">Go</a>, <a href="https://vuejs.org">Vue</a> and the <a href="https://prometheus.io">Prometheus</a> client library.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
|
||||
// Theme toggle
|
||||
var tbtn = document.getElementById("theme-toggle");
|
||||
if (tbtn) {
|
||||
tbtn.hidden = false;
|
||||
var current = function () {
|
||||
var t = root.getAttribute("data-theme");
|
||||
if (t) return t;
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
};
|
||||
var label = function () { tbtn.textContent = current() === "dark" ? "Light theme" : "Dark theme"; };
|
||||
label();
|
||||
tbtn.addEventListener("click", function () {
|
||||
var next = current() === "dark" ? "light" : "dark";
|
||||
root.setAttribute("data-theme", next);
|
||||
try { localStorage.setItem("ghtracker-theme", next); } catch (e) {}
|
||||
label();
|
||||
});
|
||||
}
|
||||
|
||||
// Copy buttons
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
return new Promise(function (resolve, reject) {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand("copy") ? resolve() : reject(); } catch (e) { reject(e); } finally { document.body.removeChild(ta); }
|
||||
});
|
||||
}
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".code[data-copy]"), function (box) {
|
||||
var code = box.querySelector("pre code");
|
||||
if (!code) return;
|
||||
var b = document.createElement("button");
|
||||
b.type = "button"; b.className = "copy"; b.textContent = "Copy";
|
||||
b.setAttribute("aria-label", "Copy code to clipboard");
|
||||
b.addEventListener("click", function () {
|
||||
copyText(code.textContent).then(function () {
|
||||
b.textContent = "Copied"; b.classList.add("done");
|
||||
setTimeout(function () { b.textContent = "Copy"; b.classList.remove("done"); }, 1600);
|
||||
}, function () { b.textContent = "Press Ctrl+C"; setTimeout(function () { b.textContent = "Copy"; }, 1600); });
|
||||
});
|
||||
box.appendChild(b);
|
||||
});
|
||||
|
||||
// Install tabs
|
||||
Array.prototype.forEach.call(document.querySelectorAll("[data-tabs]"), function (group) {
|
||||
var panels = Array.prototype.slice.call(group.querySelectorAll(".tabpanel"));
|
||||
if (!panels.length) return;
|
||||
var list = document.createElement("div");
|
||||
list.className = "tablist"; list.setAttribute("role", "tablist"); list.setAttribute("aria-label", "Install methods");
|
||||
var tabs = panels.map(function (p, i) {
|
||||
var t = document.createElement("button");
|
||||
t.type = "button"; t.setAttribute("role", "tab"); t.id = "t-" + p.id;
|
||||
t.setAttribute("aria-controls", p.id); t.textContent = p.getAttribute("data-tab-title");
|
||||
p.setAttribute("role", "tabpanel"); p.setAttribute("aria-labelledby", t.id);
|
||||
t.addEventListener("click", function () { select(i, true); });
|
||||
t.addEventListener("keydown", function (e) {
|
||||
var n = null;
|
||||
if (e.key === "ArrowRight") n = (i + 1) % tabs.length;
|
||||
else if (e.key === "ArrowLeft") n = (i - 1 + tabs.length) % tabs.length;
|
||||
else if (e.key === "Home") n = 0;
|
||||
else if (e.key === "End") n = tabs.length - 1;
|
||||
if (n !== null) { e.preventDefault(); select(n, true); }
|
||||
});
|
||||
list.appendChild(t);
|
||||
return t;
|
||||
});
|
||||
function select(idx, focus) {
|
||||
tabs.forEach(function (t, i) {
|
||||
var on = i === idx;
|
||||
t.setAttribute("aria-selected", on ? "true" : "false");
|
||||
t.tabIndex = on ? 0 : -1;
|
||||
panels[i].hidden = !on;
|
||||
});
|
||||
if (focus) tabs[idx].focus();
|
||||
}
|
||||
group.insertBefore(list, group.firstChild);
|
||||
var start = 0;
|
||||
var h = location.hash.replace("#", "");
|
||||
panels.forEach(function (p, i) { if (p.id === h) start = i; });
|
||||
select(start, false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/lukaszraczylo/ghtracker
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
@@ -12,9 +12,9 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.72.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
@@ -12,26 +10,26 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
|
||||
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
|
||||
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/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/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+106
-11
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
|
||||
const repoFetchTimeout = 5 * time.Minute
|
||||
|
||||
var (
|
||||
// ErrUnknownRepo means the name is not in the configured repository list.
|
||||
ErrUnknownRepo = errors.New("repository is not configured")
|
||||
// ErrRefreshBusy means a full refresh or a refresh of the same repository is running.
|
||||
ErrRefreshBusy = errors.New("a refresh is already running")
|
||||
)
|
||||
|
||||
// Fetcher loads the data for one "owner/name" repository.
|
||||
type Fetcher interface {
|
||||
FetchRepo(ctx context.Context, full string) (model.Repo, error)
|
||||
@@ -52,9 +60,12 @@ type Collector struct {
|
||||
lastRefresh time.Time
|
||||
fetch Fetcher
|
||||
repos map[string]model.Repo
|
||||
inflight map[string]struct{}
|
||||
now func() time.Time
|
||||
log *slog.Logger
|
||||
webBase string
|
||||
kick chan struct{}
|
||||
onRefresh []func(context.Context, View)
|
||||
names []string
|
||||
th config.Thresholds
|
||||
interval time.Duration
|
||||
@@ -78,9 +89,11 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
|
||||
interval: cfg.RefreshInterval,
|
||||
concurrency: cfg.Concurrency,
|
||||
now: time.Now,
|
||||
webBase: config.WebBase(cfg.GitHub.APIURL),
|
||||
log: log,
|
||||
kick: make(chan struct{}, 1),
|
||||
repos: make(map[string]model.Repo, len(cfg.Repos)),
|
||||
inflight: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,19 +101,36 @@ func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
|
||||
func (c *Collector) Run(ctx context.Context) {
|
||||
t := time.NewTicker(c.interval)
|
||||
defer t.Stop()
|
||||
c.Refresh(ctx)
|
||||
c.refreshAndNotify(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
c.Refresh(ctx)
|
||||
c.refreshAndNotify(ctx)
|
||||
case <-c.kick:
|
||||
c.Refresh(ctx)
|
||||
c.refreshAndNotify(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnRefresh registers fn to run after every scan started by Run; call it before Run.
|
||||
func (c *Collector) OnRefresh(fn func(context.Context, View)) { c.onRefresh = append(c.onRefresh, fn) }
|
||||
|
||||
func (c *Collector) refreshAndNotify(ctx context.Context) {
|
||||
c.Refresh(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.notify(ctx, c.View())
|
||||
}
|
||||
|
||||
func (c *Collector) notify(ctx context.Context, v View) {
|
||||
for _, fn := range c.onRefresh {
|
||||
fn(ctx, v)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued.
|
||||
func (c *Collector) RequestRefresh() bool {
|
||||
if c.refreshing.Load() {
|
||||
@@ -134,7 +164,7 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
results[i] = c.refreshOne(ctx, name, start)
|
||||
results[i] = c.refreshOne(ctx, name, start, true)
|
||||
c.scanDone.Add(1)
|
||||
}()
|
||||
}
|
||||
@@ -152,18 +182,28 @@ func (c *Collector) Refresh(ctx context.Context) {
|
||||
c.log.Info("refresh complete", "repos", len(c.names), "duration", c.lastDur.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) model.Repo {
|
||||
ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
|
||||
defer cancel()
|
||||
// refreshOne fetches one repo. With respectLimit set, it skips the fetch once the scan hit a rate limit.
|
||||
func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time, respectLimit bool) model.Repo {
|
||||
var r model.Repo
|
||||
err := model.ErrRateLimited
|
||||
// Once any repo hits the limit, remaining repos are skipped so no further requests go out.
|
||||
if !c.limited.Load() {
|
||||
r, err = c.fetch.FetchRepo(ctx, name)
|
||||
if !respectLimit || !c.limited.Load() {
|
||||
r, err = c.fetchRepo(ctx, name)
|
||||
}
|
||||
if errors.Is(err, model.ErrRateLimited) {
|
||||
if respectLimit && errors.Is(err, model.ErrRateLimited) {
|
||||
c.limited.Store(true)
|
||||
}
|
||||
return c.settle(name, at, r, err)
|
||||
}
|
||||
|
||||
func (c *Collector) fetchRepo(ctx context.Context, name string) (model.Repo, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout)
|
||||
defer cancel()
|
||||
return c.fetch.FetchRepo(ctx, name)
|
||||
}
|
||||
|
||||
// settle turns a fetch result into the repo to store; a failure keeps the last good data.
|
||||
func (c *Collector) settle(name string, at time.Time, r model.Repo, err error) model.Repo {
|
||||
if err == nil {
|
||||
r.FullName, r.Up, r.RefreshedAt = name, true, at
|
||||
return r
|
||||
@@ -174,12 +214,67 @@ func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) m
|
||||
prev, ok := c.repos[name]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
prev = model.Repo{FullName: name, URL: "https://github.com/" + name}
|
||||
prev = model.Repo{FullName: name, URL: c.webBase + "/" + name}
|
||||
}
|
||||
prev.Up, prev.Err = false, err.Error()
|
||||
return prev
|
||||
}
|
||||
|
||||
// RefreshRepo fetches one configured repo now and replaces only its state, leaving the schedule,
|
||||
// last scan time and other repos untouched. It fails with ErrRefreshBusy while a full refresh or a
|
||||
// refresh of the same repo runs. The GitHub client still enforces the rate-limit reserve.
|
||||
func (c *Collector) RefreshRepo(ctx context.Context, name string) (View, error) {
|
||||
canon, ok := c.configured(name)
|
||||
if !ok {
|
||||
return View{}, ErrUnknownRepo
|
||||
}
|
||||
if c.refreshing.Load() || !c.claim(canon) {
|
||||
return View{}, ErrRefreshBusy
|
||||
}
|
||||
defer c.release(canon)
|
||||
|
||||
at := c.now()
|
||||
r, err := c.fetchRepo(ctx, canon)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return View{}, ctx.Err()
|
||||
}
|
||||
r = c.settle(canon, at, r, err)
|
||||
c.mu.Lock()
|
||||
c.repos[canon] = r
|
||||
c.mu.Unlock()
|
||||
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) {
|
||||
for _, n := range c.names {
|
||||
if strings.EqualFold(n, name) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *Collector) claim(name string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, busy := c.inflight[name]; busy {
|
||||
return false
|
||||
}
|
||||
c.inflight[name] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Collector) release(name string) {
|
||||
c.mu.Lock()
|
||||
delete(c.inflight, name)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stats returns refresh-loop counters.
|
||||
func (c *Collector) Stats() Stats {
|
||||
c.mu.RLock()
|
||||
|
||||
@@ -66,19 +66,19 @@ func TestEvaluate(t *testing.T) {
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
|
||||
}, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
|
||||
"pr fresh": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-47 * time.Hour)}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"pr waiting warn": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-49 * time.Hour)}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-49 * time.Hour)}}
|
||||
}, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn},
|
||||
"pr waiting crit": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-8 * 24 * time.Hour)}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-8 * 24 * time.Hour)}}
|
||||
}, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
|
||||
"draft pr ignored": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
"pr checks failing": {func(r *model.Repo) {
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
|
||||
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
|
||||
"issues stale aggregated": {func(r *model.Repo) {
|
||||
old := t0.Add(-40 * 24 * time.Hour)
|
||||
@@ -86,14 +86,14 @@ func TestEvaluate(t *testing.T) {
|
||||
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
|
||||
"archived repo skips work alerts": {func(r *model.Repo) {
|
||||
r.Archived = true
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-30 * 24 * time.Hour)}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-30 * 24 * time.Hour)}}
|
||||
}, map[string]model.Severity{}, model.OK},
|
||||
}
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := fresh()
|
||||
tt.mutate(&r)
|
||||
alerts := Evaluate(&r, t0, th())
|
||||
alerts := withoutPROpen(Evaluate(&r, t0, th()))
|
||||
got := kinds(alerts)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want)
|
||||
@@ -110,23 +110,34 @@ func TestEvaluate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// withoutPROpen drops the informational pr_open alerts so tests of other kinds stay focused.
|
||||
func withoutPROpen(in []model.Alert) []model.Alert {
|
||||
var out []model.Alert
|
||||
for _, a := range in {
|
||||
if a.Kind != KindPROpen {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEvaluateDisabledThresholds(t *testing.T) {
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-100 * 24 * time.Hour)}},
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-100 * 24 * time.Hour)}},
|
||||
Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
|
||||
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
|
||||
if a := Evaluate(&r, t0, off); len(a) != 0 {
|
||||
if a := withoutPROpen(Evaluate(&r, t0, off)); len(a) != 0 {
|
||||
t.Fatalf("disabled checks produced %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeAdvancesWithoutRefresh(t *testing.T) {
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}}
|
||||
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-40 * time.Hour)}}}
|
||||
cfg := th()
|
||||
cfg.DataStaleAfter = -1
|
||||
if a := Evaluate(&r, t0, cfg); len(a) != 0 {
|
||||
if a := withoutPROpen(Evaluate(&r, t0, cfg)); len(a) != 0 {
|
||||
t.Fatalf("unexpected %+v", a)
|
||||
}
|
||||
if a := Evaluate(&r, t0.Add(10*time.Hour), cfg); len(a) != 1 || a[0].Severity != model.Warn {
|
||||
if a := withoutPROpen(Evaluate(&r, t0.Add(10*time.Hour), cfg)); len(a) != 1 || a[0].Severity != model.Warn {
|
||||
t.Fatalf("PR must cross the warn threshold as time passes: %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -230,7 +241,7 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
||||
c := &model.Run{Status: "completed", Conclusion: "failure"}
|
||||
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
|
||||
case "o/warn":
|
||||
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-72 * time.Hour)}}
|
||||
r.PRs = []model.PullRequest{{Number: 1, Fork: true, CreatedAt: t0.Add(-72 * time.Hour)}}
|
||||
}
|
||||
return r, nil
|
||||
}}
|
||||
@@ -240,7 +251,8 @@ func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
|
||||
if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" {
|
||||
t.Fatalf("order: %s %s %s", v.Repos[0].FullName, v.Repos[1].FullName, v.Repos[2].FullName)
|
||||
}
|
||||
if len(v.Alerts) != 2 || v.Alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
|
||||
alerts := withoutPROpen(v.Alerts)
|
||||
if len(alerts) != 2 || alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 {
|
||||
t.Fatalf("alerts: %+v", v.Alerts)
|
||||
}
|
||||
}
|
||||
@@ -337,3 +349,207 @@ func TestScanProgressAdvancesAsReposFinish(t *testing.T) {
|
||||
t.Fatalf("after scan: done=%d total=%d", v.ScanDone, v.ScanTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedFirstScanLinksToTheConfiguredHost(t *testing.T) {
|
||||
fail := &fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}
|
||||
cfg := &config.Config{
|
||||
Repos: []string{"o/a"}, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th(),
|
||||
GitHub: config.GitHub{APIURL: "https://ghe.example.com/api/v3"},
|
||||
}
|
||||
c := New(fail, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
c.now = func() time.Time { return t0 }
|
||||
c.Refresh(context.Background())
|
||||
if got := c.View().Repos[0].URL; got != "https://ghe.example.com/o/a" {
|
||||
t.Fatalf("repo link = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoReplacesOnlyThatRepo(t *testing.T) {
|
||||
gen := 0
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, gen)}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a", "o/b")
|
||||
c.Refresh(context.Background())
|
||||
before := c.View()
|
||||
gen = 2
|
||||
v, err := c.RefreshRepo(context.Background(), "O/B")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issues := map[string]int{}
|
||||
for _, r := range v.Repos {
|
||||
issues[r.FullName] = len(r.Issues)
|
||||
}
|
||||
if issues["o/a"] != 0 || issues["o/b"] != 2 {
|
||||
t.Fatalf("only o/b must change: %v", issues)
|
||||
}
|
||||
if !v.LastRefresh.Equal(before.LastRefresh) || c.Stats().RefreshTotal != 1 {
|
||||
t.Fatalf("full-scan bookkeeping must not move: %v %d", v.LastRefresh, c.Stats().RefreshTotal)
|
||||
}
|
||||
if len(f.calls) != 3 || f.calls[2] != "o/b" {
|
||||
t.Fatalf("calls %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoFailureKeepsLastGoodDataAndAlerts(t *testing.T) {
|
||||
fail := false
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
if fail {
|
||||
return model.Repo{}, errors.New("boom")
|
||||
}
|
||||
return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
c.Refresh(context.Background())
|
||||
fail = true
|
||||
v, err := c.RefreshRepo(context.Background(), "o/a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := v.Repos[0]; r.Up || r.Err != "boom" || len(r.Issues) != 1 || v.Crit != 1 {
|
||||
t.Fatalf("%+v crit=%d", r, v.Crit)
|
||||
}
|
||||
if c.Stats().RepoErrorsTotal != 1 {
|
||||
t.Fatalf("stats %+v", c.Stats())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoIgnoresScanRateLimitFlag(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
c.limited.Store(true)
|
||||
v, err := c.RefreshRepo(context.Background(), "o/a")
|
||||
if err != nil || !v.Repos[0].Up {
|
||||
t.Fatalf("err=%v %+v", err, v.Repos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoRejections(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
|
||||
if _, err := c.RefreshRepo(context.Background(), "x/y"); !errors.Is(err, ErrUnknownRepo) {
|
||||
t.Fatalf("unknown: %v", err)
|
||||
}
|
||||
c.refreshing.Store(true)
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
|
||||
t.Fatalf("full refresh running: %v", err)
|
||||
}
|
||||
c.refreshing.Store(false)
|
||||
if !c.claim("o/a") {
|
||||
t.Fatal("claim")
|
||||
}
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); !errors.Is(err, ErrRefreshBusy) {
|
||||
t.Fatalf("same repo running: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoCancelledKeepsState(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := &fakeFetcher{fn: func(string) (model.Repo, error) { cancel(); return model.Repo{}, context.Canceled }}
|
||||
c := newCollector(f, "o/a")
|
||||
if _, err := c.RefreshRepo(ctx, "o/a"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(c.View().Repos) != 0 || c.Stats().RepoErrorsTotal != 0 {
|
||||
t.Fatal("a cancelled request must not mark the repo down")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoConcurrentWithFullRefreshAndScheduled(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}
|
||||
c := newCollector(f, "o/a", "o/b")
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(3)
|
||||
go func() { defer wg.Done(); c.Refresh(context.Background()) }()
|
||||
go func() { defer wg.Done(); _, _ = c.RefreshRepo(context.Background(), "o/a") }()
|
||||
go func() { defer wg.Done(); _ = c.View(); _ = c.Stats() }()
|
||||
}
|
||||
wg.Wait()
|
||||
if len(c.View().Repos) != 2 {
|
||||
t.Fatalf("%+v", c.View().Repos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePROpen(t *testing.T) {
|
||||
pr := func(mut func(*model.PullRequest)) model.PullRequest {
|
||||
p := model.PullRequest{Number: 7, Title: "fix", URL: "https://github.com/o/r/pull/7", Author: "alice", HeadSHA: "abc123", CreatedAt: t0.Add(-5 * time.Hour)}
|
||||
mut(&p)
|
||||
return p
|
||||
}
|
||||
tests := map[string]struct {
|
||||
pr model.PullRequest
|
||||
archived bool
|
||||
want bool
|
||||
}{
|
||||
"open": {pr(func(*model.PullRequest) {}), false, true},
|
||||
"fork": {pr(func(p *model.PullRequest) { p.Fork = true }), false, true},
|
||||
"draft": {pr(func(p *model.PullRequest) { p.Draft = true }), false, false},
|
||||
"archived": {pr(func(*model.PullRequest) {}), true, false},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: t0, Archived: tc.archived, PRs: []model.PullRequest{tc.pr}}
|
||||
alerts := Evaluate(&r, t0, th())
|
||||
if r.Health != model.OK {
|
||||
t.Fatalf("health = %v, want ok", r.Health)
|
||||
}
|
||||
if !tc.want {
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("alerts = %+v, want none", alerts)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := model.Alert{Severity: model.OK, Repo: "o/r", Kind: KindPROpen, Subject: "#7 fix", Detail: "open for 5h",
|
||||
URL: tc.pr.URL, Ref: "abc123", Author: "alice", Since: tc.pr.CreatedAt}
|
||||
if len(alerts) != 1 || alerts[0] != want {
|
||||
t.Fatalf("alerts = %+v, want [%+v]", alerts, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPROpenDoesNotChangeCounts(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
return model.Repo{FullName: name, PRs: []model.PullRequest{{Number: 1, HeadSHA: "s", CreatedAt: t0}}}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
c.Refresh(context.Background())
|
||||
v := c.View()
|
||||
if len(v.Alerts) != 1 || v.Alerts[0].Kind != KindPROpen || v.Crit != 0 || v.Warn != 0 || v.Repos[0].Health != model.OK {
|
||||
t.Fatalf("view = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoRunsRefreshHooksWithTheNewView(t *testing.T) {
|
||||
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
|
||||
return model.Repo{FullName: name, URL: "u", Issues: make([]model.Issue, 3)}, nil
|
||||
}}
|
||||
c := newCollector(f, "o/a")
|
||||
got := make(chan View, 1)
|
||||
c.OnRefresh(func(_ context.Context, v View) { got <- v })
|
||||
if _, err := c.RefreshRepo(context.Background(), "o/a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case v := <-got:
|
||||
if len(v.Repos) != 1 || len(v.Repos[0].Issues) != 3 {
|
||||
t.Fatalf("hook saw a stale view: %+v", v.Repos)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("refresh hook did not run after a repository refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRepoFailureSkipsRefreshHooks(t *testing.T) {
|
||||
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
|
||||
called := make(chan struct{}, 1)
|
||||
c.OnRefresh(func(context.Context, View) { called <- struct{}{} })
|
||||
if _, err := c.RefreshRepo(context.Background(), "unknown/x"); err == nil {
|
||||
t.Fatal("unknown repository must fail")
|
||||
}
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("hook ran for a refused refresh")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
KindPRWaiting = "pr_waiting"
|
||||
KindPRChecks = "pr_checks_failing"
|
||||
KindIssuesStale = "issues_stale"
|
||||
// KindPROpen is informational plumbing for automatic actions; it has OK severity so it never moves health or counts.
|
||||
KindPROpen = "pr_open"
|
||||
)
|
||||
|
||||
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
|
||||
@@ -62,6 +64,8 @@ func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert
|
||||
}
|
||||
age := now.Sub(pr.CreatedAt)
|
||||
subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title)
|
||||
alerts = append(alerts, model.Alert{Severity: model.OK, Repo: r.FullName, Kind: KindPROpen, Subject: subject,
|
||||
Detail: "open for " + Age(age), URL: pr.URL, Ref: pr.HeadSHA, Author: pr.Author, Since: pr.CreatedAt})
|
||||
switch {
|
||||
case th.PRCritAfter > 0 && age >= th.PRCritAfter:
|
||||
add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
|
||||
|
||||
@@ -4,7 +4,9 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,13 +29,66 @@ const (
|
||||
MinRefresh = time.Minute
|
||||
dataStaleGrace = 30 * time.Minute
|
||||
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 {
|
||||
// ManualRefresh enables POST /refresh and the rescan button; it defaults to on.
|
||||
ManualRefresh *bool `yaml:"manual_refresh"`
|
||||
Listen string `yaml:"listen"`
|
||||
Repos []string `yaml:"repos"`
|
||||
Actions []Action `yaml:"actions"`
|
||||
GitHub GitHub `yaml:"github"`
|
||||
Thresholds Thresholds `yaml:"thresholds"`
|
||||
RefreshInterval time.Duration `yaml:"refresh_interval"`
|
||||
@@ -126,6 +181,10 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
|
||||
c.GitHub.PrivateKeyPEM = string(pem)
|
||||
}
|
||||
|
||||
if err := finalizeActions(c.Actions, getenv); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repos, err := normalizeRepos(c.Repos)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,6 +193,49 @@ func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]b
|
||||
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) {
|
||||
if *d == 0 {
|
||||
*d = def
|
||||
@@ -161,3 +263,24 @@ func normalizeRepos(in []string) ([]string, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultWebURL = "https://github.com"
|
||||
apiV3Suffix = "/api/v3"
|
||||
apiHostPrefix = "api."
|
||||
)
|
||||
|
||||
// WebBase returns the address of the web interface that belongs to a GitHub API address:
|
||||
// github.com for the public API, the host without /api/v3 for Enterprise Server, and the host
|
||||
// without its "api." prefix for Enterprise Cloud with data residency.
|
||||
func WebBase(apiURL string) string {
|
||||
u, err := url.Parse(strings.TrimRight(apiURL, "/"))
|
||||
if err != nil || u.Host == "" || strings.EqualFold(u.Host, "api.github.com") {
|
||||
return defaultWebURL
|
||||
}
|
||||
u.Path = strings.TrimSuffix(u.Path, apiV3Suffix)
|
||||
if u.Path == "" {
|
||||
u.Host = strings.TrimPrefix(u.Host, apiHostPrefix)
|
||||
}
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -126,3 +127,120 @@ func TestManualRefreshCanBeDisabled(t *testing.T) {
|
||||
t.Fatal("explicit false must stay off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebBase(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "https://github.com",
|
||||
"https://api.github.com": "https://github.com",
|
||||
"https://api.github.com/": "https://github.com",
|
||||
"https://ghe.example.com/api/v3": "https://ghe.example.com",
|
||||
"https://ghe.example.com/api/v3/": "https://ghe.example.com",
|
||||
"http://ghe.internal:8080/api/v3": "http://ghe.internal:8080",
|
||||
"https://api.acme.ghe.com": "https://acme.ghe.com",
|
||||
"https://github.example.com/sub/api/v3": "https://github.example.com/sub",
|
||||
"not a url": "https://github.com",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := WebBase(in); got != want {
|
||||
t.Errorf("WebBase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+31
-10
@@ -7,20 +7,23 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFailingNames = 5
|
||||
runsPerWorkflow = 5
|
||||
maxWorkflows = 100
|
||||
eventPullRequest = "pull_request"
|
||||
eventPullReqTgt = "pull_request_target"
|
||||
statusStateFail = "failure"
|
||||
statusStateError = "error"
|
||||
statusStatePend = "pending"
|
||||
maxFailingNames = 5
|
||||
runsPerWorkflow = 5
|
||||
// freshRunsWindowDays bounds the created filter of the confirming run query.
|
||||
freshRunsWindowDays = 30
|
||||
maxWorkflows = 100
|
||||
eventPullRequest = "pull_request"
|
||||
eventPullReqTgt = "pull_request_target"
|
||||
statusStateFail = "failure"
|
||||
statusStateError = "error"
|
||||
statusStatePend = "pending"
|
||||
)
|
||||
|
||||
// backgroundEvents are triggers that run without a person pushing code.
|
||||
@@ -129,8 +132,12 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest,
|
||||
HTMLURL string `json:"html_url"`
|
||||
User userRef `json:"user"`
|
||||
Head struct {
|
||||
SHA string `json:"sha"`
|
||||
Repo *repoRef `json:"repo"`
|
||||
SHA string `json:"sha"`
|
||||
} `json:"head"`
|
||||
Base struct {
|
||||
Repo *repoRef `json:"repo"`
|
||||
} `json:"base"`
|
||||
Labels []labelRef `json:"labels"`
|
||||
Number int `json:"number"`
|
||||
Draft bool `json:"draft"`
|
||||
@@ -141,13 +148,22 @@ func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest,
|
||||
for _, p := range items {
|
||||
out = append(out, model.PullRequest{
|
||||
Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft,
|
||||
Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, Fork: isFork(p.Head.Repo, p.Base.Repo), CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt,
|
||||
Checks: model.CheckSummary{State: model.ChecksUnknown},
|
||||
})
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// isFork reports a head repo that differs from the base repo; a deleted head repo counts as a fork.
|
||||
func isFork(head, base *repoRef) bool {
|
||||
return head == nil || base == nil || !strings.EqualFold(head.FullName, base.FullName)
|
||||
}
|
||||
|
||||
// attachChecks fills each PR's check summary; it reports the first failure but still tries every PR.
|
||||
func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error {
|
||||
var first error
|
||||
@@ -360,6 +376,11 @@ func (c *Client) workflowRuns(ctx context.Context, full string, id int64, branch
|
||||
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, id)
|
||||
get := c.getJSON
|
||||
if fresh {
|
||||
// GitHub can keep answering a branch query from an old cached result, for weeks, whatever
|
||||
// the connection, and page=1 alone does not skip it. A created filter changes the query
|
||||
// every day and reads current runs.
|
||||
q.Set("page", "1")
|
||||
q.Set("created", ">="+c.now().UTC().AddDate(0, 0, -freshRunsWindowDays).Format(time.DateOnly))
|
||||
get = c.getJSONFresh
|
||||
}
|
||||
if err := get(ctx, full, path, q, &out); err != nil {
|
||||
|
||||
+31
-5
@@ -460,13 +460,13 @@ func TestAPIErrorMessageParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// staleThenFresh serves an old failing run on the first request and the current runs afterwards,
|
||||
// like a lagging GitHub replica followed by a healthy one.
|
||||
// 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 a created filter returns current runs.
|
||||
func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
|
||||
var calls atomic.Int64
|
||||
f.routes[path] = func(w http.ResponseWriter, r *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n == 1 {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("created") == "" {
|
||||
_, _ = w.Write([]byte(`{"workflow_runs":[
|
||||
{"id":1,"html_url":"old","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-09-11T03:02:00Z"}]}`))
|
||||
return
|
||||
@@ -482,7 +482,7 @@ func staleThenFresh(f *fakeGitHub, path string) *atomic.Int64 {
|
||||
return &calls
|
||||
}
|
||||
|
||||
func TestStaleFailureIsConfirmedOnANewConnection(t *testing.T) {
|
||||
func TestStaleFailureIsConfirmedWithADifferentQuery(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 10)
|
||||
f.json("/repos/o/r/actions/workflows", `{"workflows":[{"id":7,"name":"Autoupdate","html_url":"w","state":"active"}]}`)
|
||||
@@ -553,3 +553,29 @@ func TestFreshClientDisablesConnectionReuse(t *testing.T) {
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestOpenPRsHeadSHAAndFork(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
pr string
|
||||
wantFork bool
|
||||
}{
|
||||
"same repo": {`"head":{"sha":"abc","repo":{"full_name":"o/r"}},"base":{"repo":{"full_name":"o/r"}}`, false},
|
||||
"same repo casing": {`"head":{"sha":"abc","repo":{"full_name":"O/R"}},"base":{"repo":{"full_name":"o/r"}}`, false},
|
||||
"other repo": {`"head":{"sha":"abc","repo":{"full_name":"x/r"}},"base":{"repo":{"full_name":"o/r"}}`, true},
|
||||
"deleted head": {`"head":{"sha":"abc","repo":null},"base":{"repo":{"full_name":"o/r"}}`, true},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newFake(t)
|
||||
c := f.client(t, 50)
|
||||
f.json("/repos/o/r/pulls", `[{"number":2,"title":"t","html_url":"u2","user":{"login":"b"},`+tc.pr+`}]`)
|
||||
prs, err := c.openPRs(context.Background(), "o/r")
|
||||
if err != nil || len(prs) != 1 {
|
||||
t.Fatalf("prs = %+v, err = %v", prs, err)
|
||||
}
|
||||
if prs[0].HeadSHA != "abc" || prs[0].Fork != tc.wantFork {
|
||||
t.Fatalf("HeadSHA = %q, Fork = %v, want abc, %v", prs[0].HeadSHA, prs[0].Fork, tc.wantFork)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -90,6 +90,8 @@ type PullRequest struct {
|
||||
Checks CheckSummary
|
||||
Number int
|
||||
Draft bool
|
||||
// Fork is true when the head repository differs from the base or was deleted.
|
||||
Fork bool
|
||||
}
|
||||
|
||||
type CheckSummary struct {
|
||||
@@ -133,12 +135,16 @@ func (r Run) Failed() bool {
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
Since time.Time
|
||||
Repo string
|
||||
Kind string
|
||||
Subject string
|
||||
Detail string
|
||||
URL string
|
||||
Since time.Time
|
||||
Repo string
|
||||
Kind string
|
||||
Subject string
|
||||
Detail string
|
||||
URL string
|
||||
// Ref is the head commit of a pr_open alert; empty for other kinds.
|
||||
Ref string
|
||||
// Author is the pull request author of a pr_open alert; it is not part of the JSON API.
|
||||
Author string
|
||||
Severity Severity
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
statusCacheTTL = 10 * time.Second
|
||||
maxActionBody = 4 << 10
|
||||
maxUpstreamBody = 1 << 20
|
||||
maxErrorChars = 300
|
||||
actionsUnavail = "action status unavailable"
|
||||
resultOK = "ok"
|
||||
resultRejected = "rejected"
|
||||
resultFailed = "error"
|
||||
resultAutoOK = "auto_ok"
|
||||
resultAutoBusy = "auto_busy"
|
||||
resultAutoRefused = "auto_rejected"
|
||||
resultAutoFailed = "auto_error"
|
||||
actionMetricName = "action_requests_total"
|
||||
contentTypeHeader = "Content-Type"
|
||||
)
|
||||
|
||||
// apiAction is the public description of an action; it never carries URLs or tokens.
|
||||
type apiAction struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Confirm string `json:"confirm,omitempty"`
|
||||
Kinds []string `json:"kinds"`
|
||||
}
|
||||
|
||||
// apiActionStatus is what the UI shows next to an alert after an action ran.
|
||||
type apiActionStatus struct {
|
||||
State string `json:"state"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Link string `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
type statusRow struct {
|
||||
Repo string `json:"repo"`
|
||||
URL string `json:"url"`
|
||||
apiActionStatus
|
||||
}
|
||||
|
||||
type alertRef struct{ repo, url string }
|
||||
|
||||
// actionRuntime holds one action and the cache of its status rows.
|
||||
type actionRuntime struct {
|
||||
fetched time.Time
|
||||
rows map[alertRef]apiActionStatus
|
||||
err error
|
||||
cfg config.Action
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Actions runs the configured webhook actions and merges their status into the state.
|
||||
type Actions struct {
|
||||
now func() time.Time
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
counter *prometheus.CounterVec
|
||||
byID map[string]*actionRuntime
|
||||
list []*actionRuntime
|
||||
}
|
||||
|
||||
// NewActions builds the runner and registers ghtracker_action_requests_total on reg when reg is not nil.
|
||||
func NewActions(cfgs []config.Action, log *slog.Logger, reg prometheus.Registerer) *Actions {
|
||||
a := &Actions{
|
||||
now: time.Now, log: log, byID: make(map[string]*actionRuntime, len(cfgs)),
|
||||
// Redirects would carry the bearer token to another host, so none are followed.
|
||||
hc: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
|
||||
counter: prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "ghtracker", Name: actionMetricName,
|
||||
Help: "Action requests sent to webhooks, by action and result.",
|
||||
}, []string{"action", "result"}),
|
||||
}
|
||||
for _, c := range cfgs {
|
||||
rt := &actionRuntime{cfg: c}
|
||||
a.list = append(a.list, rt)
|
||||
a.byID[c.ID] = rt
|
||||
for _, r := range []string{resultOK, resultRejected, resultFailed} {
|
||||
a.counter.WithLabelValues(c.ID, r)
|
||||
}
|
||||
if c.Auto {
|
||||
for _, r := range []string{resultAutoOK, resultAutoBusy, resultAutoRefused, resultAutoFailed} {
|
||||
a.counter.WithLabelValues(c.ID, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if reg != nil {
|
||||
reg.MustRegister(a.counter)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// WithActions enables POST /api/actions/{id} and the action data in the state document.
|
||||
func (s *Server) WithActions(a *Actions) *Server {
|
||||
if a != nil && len(a.list) > 0 {
|
||||
s.actions = a
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// decorate adds the action list and per-alert status to a state document.
|
||||
func (s *Server) decorate(ctx context.Context, st *apiState) {
|
||||
if s.actions == nil {
|
||||
return
|
||||
}
|
||||
for _, rt := range s.actions.list {
|
||||
kinds := rt.cfg.Kinds
|
||||
if kinds == nil {
|
||||
kinds = []string{}
|
||||
}
|
||||
st.Actions = append(st.Actions, apiAction{ID: rt.cfg.ID, Label: rt.cfg.Label, Confirm: rt.cfg.Confirm, Kinds: kinds})
|
||||
if rt.cfg.StatusURL == "" {
|
||||
continue
|
||||
}
|
||||
rows, err := s.actions.statusRows(ctx, rt)
|
||||
if err != nil {
|
||||
st.ActionsError = actionsUnavail
|
||||
continue
|
||||
}
|
||||
for i := range st.Alerts {
|
||||
al := &st.Alerts[i]
|
||||
if !rt.cfg.AppliesTo(al.Kind) {
|
||||
continue
|
||||
}
|
||||
if row, ok := rows[alertRef{strings.ToLower(al.Repo), al.URL}]; ok {
|
||||
if al.ActionStatus == nil {
|
||||
al.ActionStatus = map[string]apiActionStatus{}
|
||||
}
|
||||
al.ActionStatus[rt.cfg.ID] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statusRows returns the cached rows of an action's status_url; failures are cached for the TTL too.
|
||||
func (a *Actions) statusRows(ctx context.Context, rt *actionRuntime) (map[alertRef]apiActionStatus, error) {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
if !rt.fetched.IsZero() && a.now().Sub(rt.fetched) < statusCacheTTL {
|
||||
return rt.rows, rt.err
|
||||
}
|
||||
rows, err := a.fetchStatus(ctx, rt.cfg)
|
||||
if err != nil {
|
||||
a.log.Warn("action status unavailable", "action", rt.cfg.ID, "err", err)
|
||||
}
|
||||
rt.rows, rt.err, rt.fetched = rows, err, a.now()
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (rt *actionRuntime) invalidate() {
|
||||
rt.mu.Lock()
|
||||
rt.fetched = time.Time{}
|
||||
rt.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Actions) fetchStatus(ctx context.Context, c config.Action) (map[alertRef]apiActionStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.StatusURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid status url")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
setBearer(req, c.Token)
|
||||
resp, err := a.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.New("request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
var list []statusRow
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, maxUpstreamBody)).Decode(&list); err != nil {
|
||||
return nil, errors.New("invalid reply")
|
||||
}
|
||||
out := make(map[alertRef]apiActionStatus, len(list))
|
||||
for _, r := range list { // the first row per alert wins, so put the newest first
|
||||
k := alertRef{strings.ToLower(r.Repo), r.URL}
|
||||
if _, seen := out[k]; !seen {
|
||||
out[k] = cleanStatus(r.apiActionStatus)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cleanStatus drops a link that is not http(s), so the UI never renders a script URL as an anchor.
|
||||
func cleanStatus(st apiActionStatus) apiActionStatus {
|
||||
if u, err := url.Parse(st.Link); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
st.Link = ""
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func setBearer(req *http.Request, token string) {
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
type actionRequest struct {
|
||||
Repo string `json:"repo"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type webhookAlert struct {
|
||||
Since time.Time `json:"since"`
|
||||
Repo string `json:"repo"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Subject string `json:"subject"`
|
||||
Detail string `json:"detail"`
|
||||
URL string `json:"url"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
type webhookPayload struct {
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
Action string `json:"action"`
|
||||
Alert webhookAlert `json:"alert"`
|
||||
}
|
||||
|
||||
// runAction validates the request against the current state and forwards it to the action's webhook.
|
||||
func (s *Server) runAction(w http.ResponseWriter, r *http.Request) {
|
||||
if crossSite(r) {
|
||||
http.Error(w, "cross-site action refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
rt, ok := s.actions.byID[r.PathValue("id")]
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, "unknown action")
|
||||
return
|
||||
}
|
||||
var req actionRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionBody)).Decode(&req); err != nil || req.Repo == "" || req.URL == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, `body must be {"repo":"owner/name","url":"..."}`)
|
||||
return
|
||||
}
|
||||
alert, code, msg := s.findAlert(rt.cfg, req)
|
||||
if code != 0 {
|
||||
writeJSONError(w, code, msg)
|
||||
return
|
||||
}
|
||||
status, body, err := s.actions.post(r.Context(), rt.cfg, newWebhookPayload(rt.cfg.ID, alert, s.now()))
|
||||
if err != nil {
|
||||
s.actions.count(rt.cfg.ID, resultFailed)
|
||||
s.log.Warn("action webhook failed", "action", rt.cfg.ID, "repo", alert.Repo, "err", err)
|
||||
writeJSONError(w, http.StatusBadGateway, "action webhook unreachable")
|
||||
return
|
||||
}
|
||||
s.log.Info("action webhook", "action", rt.cfg.ID, "repo", alert.Repo, "status", status)
|
||||
if status/100 != 2 {
|
||||
result, out := resultFailed, http.StatusBadGateway
|
||||
switch status / 100 {
|
||||
case 4:
|
||||
result, out = resultRejected, status
|
||||
case 5:
|
||||
out = status
|
||||
}
|
||||
s.actions.count(rt.cfg.ID, result)
|
||||
writeJSONError(w, out, fmt.Sprintf("action webhook answered HTTP %d: %s", status, truncate(string(body), maxErrorChars)))
|
||||
return
|
||||
}
|
||||
s.actions.count(rt.cfg.ID, resultOK)
|
||||
rt.invalidate()
|
||||
var st apiActionStatus
|
||||
if json.Unmarshal(body, &st) != nil {
|
||||
st = apiActionStatus{}
|
||||
}
|
||||
w.Header().Set(contentTypeHeader, "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(cleanStatus(st))
|
||||
}
|
||||
|
||||
func newWebhookPayload(id string, a model.Alert, at time.Time) webhookPayload {
|
||||
return webhookPayload{
|
||||
Action: id, RequestedAt: at.UTC(),
|
||||
Alert: webhookAlert{Repo: a.Repo, Kind: a.Kind, Severity: a.Severity.String(), Subject: a.Subject,
|
||||
Detail: a.Detail, URL: a.URL, Ref: a.Ref, Since: a.Since},
|
||||
}
|
||||
}
|
||||
|
||||
// findAlert returns the current alert a request targets, or a status and message when it has none.
|
||||
func (s *Server) findAlert(c config.Action, req actionRequest) (model.Alert, int, string) {
|
||||
v := s.src.View()
|
||||
tracked := false
|
||||
for _, repo := range v.Repos {
|
||||
if strings.EqualFold(repo.FullName, req.Repo) {
|
||||
tracked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !tracked {
|
||||
return model.Alert{}, http.StatusNotFound, "unknown repository"
|
||||
}
|
||||
for _, a := range v.Alerts {
|
||||
if strings.EqualFold(a.Repo, req.Repo) && a.URL == req.URL && c.AppliesTo(a.Kind) {
|
||||
return a, 0, ""
|
||||
}
|
||||
}
|
||||
return model.Alert{}, http.StatusNotFound, "no such alert"
|
||||
}
|
||||
|
||||
func (a *Actions) post(ctx context.Context, c config.Action, p webhookPayload) (int, []byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.Webhook.Timeout)
|
||||
defer cancel()
|
||||
payload, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Webhook.URL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("invalid webhook url")
|
||||
}
|
||||
req.Header.Set(contentTypeHeader, "application/json")
|
||||
setBearer(req, c.Token)
|
||||
resp, err := a.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("request failed")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody))
|
||||
if err != nil {
|
||||
return 0, nil, errors.New("read reply failed")
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func (a *Actions) count(action, result string) { a.counter.WithLabelValues(action, result).Inc() }
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
testToken = "tok-SECRET-123"
|
||||
alertURL = "https://github.com/o/r/actions/runs/1"
|
||||
prURL = "https://github.com/o/r/pull/5"
|
||||
)
|
||||
|
||||
type fakeHook struct {
|
||||
srv *httptest.Server
|
||||
postBody string
|
||||
statBody string
|
||||
lastAuth string
|
||||
lastBody string
|
||||
lastMethod string
|
||||
postCode int
|
||||
statCode int
|
||||
mu sync.Mutex
|
||||
postCalls atomic.Int32
|
||||
statCalls atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeHook(t *testing.T) *fakeHook {
|
||||
t.Helper()
|
||||
f := &fakeHook{postCode: 200, postBody: `{"state":"queued","label":"Queued","link":"https://ci.example.test/run/9"}`, statCode: 200, statBody: `[]`}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f.mu.Lock()
|
||||
f.lastAuth, f.lastBody, f.lastMethod = r.Header.Get("Authorization"), string(b), r.Method
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/status" {
|
||||
f.statCalls.Add(1)
|
||||
w.WriteHeader(f.statCode)
|
||||
_, _ = w.Write([]byte(f.statBody))
|
||||
return
|
||||
}
|
||||
f.postCalls.Add(1)
|
||||
w.WriteHeader(f.postCode)
|
||||
_, _ = w.Write([]byte(f.postBody))
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
type actionHarness struct {
|
||||
srv *Server
|
||||
actions *Actions
|
||||
logs *bytes.Buffer
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newActionServer(t *testing.T, mutate func(*config.Action), base string) *actionHarness {
|
||||
t.Helper()
|
||||
c := config.Action{ID: "rerun", Label: "Re-run", Kinds: []string{"workflow_failed"}, Confirm: "Run it?",
|
||||
StatusURL: base + "/status", Token: testToken,
|
||||
Webhook: config.Webhook{URL: base + "/hook", TokenEnv: "T", Timeout: 2 * time.Second}}
|
||||
if mutate != nil {
|
||||
mutate(&c)
|
||||
}
|
||||
h := &actionHarness{logs: &bytes.Buffer{}, clock: now}
|
||||
log := slog.New(slog.NewTextHandler(h.logs, nil))
|
||||
h.actions = NewActions([]config.Action{c}, log, prometheus.NewRegistry())
|
||||
h.actions.now = func() time.Time { return h.clock }
|
||||
h.srv = New(&fakeSource{v: testView()}, http.NotFoundHandler(), testUI(), true, log).WithActions(h.actions)
|
||||
h.srv.now = func() time.Time { return now }
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *actionHarness) post(path, body string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.srv.Handler().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (h *actionHarness) state(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(do(h.srv.Handler(), "GET", "/api/state").Body.Bytes(), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func reqBody(repo, url string) string {
|
||||
b, _ := json.Marshal(map[string]string{"repo": repo, "url": url})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (h *actionHarness) counter(result string) float64 {
|
||||
return testutil.ToFloat64(h.actions.counter.WithLabelValues("rerun", result))
|
||||
}
|
||||
|
||||
func TestActionRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
hdr map[string]string
|
||||
mutate func(*config.Action)
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
postBody string
|
||||
wantResult string
|
||||
wantBody string
|
||||
postCode int
|
||||
wantCode int
|
||||
wantPosts int32
|
||||
}{
|
||||
{name: "ok passes status through", wantCode: 200, wantPosts: 1, wantResult: "ok",
|
||||
wantBody: `"state":"queued"`},
|
||||
{name: "ok with empty body", postBody: "not json", wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `"state":""`},
|
||||
{name: "repo case-insensitive", body: reqBody("O/R", alertURL), wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "javascript link dropped", postBody: `{"state":"x","link":"javascript:alert(1)"}`,
|
||||
wantCode: 200, wantPosts: 1, wantResult: "ok", wantBody: `{"state":"x"}`},
|
||||
{name: "no token configured", mutate: func(a *config.Action) { a.Token = "" }, wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "upstream 4xx", postCode: 429, postBody: `{"error":"busy"}`, wantCode: 429, wantPosts: 1, wantResult: "rejected", wantBody: "HTTP 429"},
|
||||
{name: "upstream 5xx", postCode: 500, postBody: "boom", wantCode: 500, wantPosts: 1, wantResult: "error", wantBody: "boom"},
|
||||
{name: "upstream redirect not followed", postCode: 302, postBody: "moved", wantCode: 502, wantPosts: 1, wantResult: "error"},
|
||||
{name: "upstream body truncated", postCode: 500, postBody: strings.Repeat("x", 1000), wantCode: 500, wantPosts: 1, wantResult: "error",
|
||||
wantBody: strings.Repeat("x", 300) + "..."},
|
||||
{name: "cross-site refused", hdr: map[string]string{"Sec-Fetch-Site": "cross-site"}, wantCode: 403},
|
||||
{name: "same-site refused", hdr: map[string]string{"Sec-Fetch-Site": "same-site"}, wantCode: 403},
|
||||
{name: "same-origin allowed", hdr: map[string]string{"Sec-Fetch-Site": "same-origin"}, wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "unknown action", path: "/api/actions/nope", wantCode: 404, wantBody: "unknown action"},
|
||||
{name: "unknown repo", body: reqBody("x/y", alertURL), wantCode: 404, wantBody: "unknown repository"},
|
||||
{name: "unknown alert", body: reqBody("o/r", alertURL+"9"), wantCode: 404, wantBody: "no such alert"},
|
||||
{name: "kind not in kinds", body: reqBody("o/r", prURL), wantCode: 404, wantBody: "no such alert"},
|
||||
{name: "empty kinds matches every kind", body: reqBody("o/r", prURL), mutate: func(a *config.Action) { a.Kinds = nil },
|
||||
wantCode: 200, wantPosts: 1, wantResult: "ok"},
|
||||
{name: "malformed body", body: `{`, wantCode: 400},
|
||||
{name: "missing url", body: `{"repo":"o/r"}`, wantCode: 400},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
if tc.postCode != 0 {
|
||||
fh.postCode = tc.postCode
|
||||
}
|
||||
if tc.postBody != "" {
|
||||
fh.postBody = tc.postBody
|
||||
}
|
||||
h := newActionServer(t, tc.mutate, fh.srv.URL)
|
||||
path, body := tc.path, tc.body
|
||||
if path == "" {
|
||||
path = "/api/actions/rerun"
|
||||
}
|
||||
if body == "" {
|
||||
body = reqBody("o/r", alertURL)
|
||||
}
|
||||
rec := h.post(path, body, tc.hdr)
|
||||
if rec.Code != tc.wantCode {
|
||||
t.Fatalf("code = %d, want %d: %s", rec.Code, tc.wantCode, rec.Body)
|
||||
}
|
||||
if fh.postCalls.Load() != tc.wantPosts {
|
||||
t.Fatalf("webhook posts = %d, want %d", fh.postCalls.Load(), tc.wantPosts)
|
||||
}
|
||||
if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
|
||||
t.Fatalf("body %q lacks %q", rec.Body, tc.wantBody)
|
||||
}
|
||||
if tc.wantResult != "" && h.counter(tc.wantResult) != 1 {
|
||||
t.Fatalf("counter %s = %v, want 1", tc.wantResult, h.counter(tc.wantResult))
|
||||
}
|
||||
if tc.wantPosts > 0 {
|
||||
wantAuth := "Bearer " + testToken
|
||||
if tc.mutate != nil && tc.name == "no token configured" {
|
||||
wantAuth = ""
|
||||
}
|
||||
if fh.lastAuth != wantAuth {
|
||||
t.Fatalf("auth = %q, want %q", fh.lastAuth, wantAuth)
|
||||
}
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) || strings.Contains(rec.Body.String(), testToken) {
|
||||
t.Fatal("token leaked into logs or response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWebhookPayload(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != 200 {
|
||||
t.Fatal(rec.Body)
|
||||
}
|
||||
var got struct {
|
||||
RequestedAt time.Time
|
||||
Alert map[string]string
|
||||
Action string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(fh.lastBody), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fh.lastMethod != "POST" || got.Action != "rerun" || !got.RequestedAt.Equal(now) {
|
||||
t.Fatalf("payload: %+v (%s)", got, fh.lastBody)
|
||||
}
|
||||
want := map[string]string{"repo": "o/r", "kind": "workflow_failed", "severity": "crit", "subject": "CI", "url": alertURL}
|
||||
for k, v := range want {
|
||||
if got.Alert[k] != v {
|
||||
t.Errorf("alert.%s = %q, want %q", k, got.Alert[k], v)
|
||||
}
|
||||
}
|
||||
if got.Alert["detail"] == "" || got.Alert["since"] == "" {
|
||||
t.Errorf("alert lacks detail or since: %v", got.Alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionWebhookDown(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
base := fh.srv.URL
|
||||
fh.srv.Close()
|
||||
h := newActionServer(t, nil, base)
|
||||
rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
|
||||
if rec.Code != http.StatusBadGateway || h.counter("error") != 1 {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) {
|
||||
t.Fatal("token leaked into logs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRouteAbsentWithoutActions(t *testing.T) {
|
||||
h := newServer(&fakeSource{v: testView()}, testUI(), true).Handler()
|
||||
if rec := do(h, "POST", "/api/actions/rerun"); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateUnchangedWithoutActions(t *testing.T) {
|
||||
body := do(newServer(&fakeSource{v: testView()}, testUI(), true).Handler(), "GET", "/api/state").Body.String()
|
||||
for _, key := range []string{`"actions"`, `"actionStatus"`, `"actionsError"`} {
|
||||
if strings.Contains(body, key) {
|
||||
t.Fatalf("state gained %s without actions: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alertField(m map[string]any, kind, field string) any {
|
||||
for _, a := range m["alerts"].([]any) {
|
||||
if al := a.(map[string]any); al["kind"] == kind {
|
||||
return al[field]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStateMergesActionStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
wantState any
|
||||
wantLink any
|
||||
mutate func(*config.Action)
|
||||
name string
|
||||
rows string
|
||||
}{
|
||||
{name: "match by repo and url", rows: `[{"repo":"O/R","url":"` + alertURL + `","state":"running","label":"Running","link":"https://ci.example.test/1"}]`,
|
||||
wantState: "running", wantLink: "https://ci.example.test/1"},
|
||||
{name: "first row wins", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"new"},{"repo":"o/r","url":"` + alertURL + `","state":"old"}]`,
|
||||
wantState: "new"},
|
||||
{name: "other url ignored", rows: `[{"repo":"o/r","url":"` + alertURL + `x","state":"running"}]`},
|
||||
{name: "other repo ignored", rows: `[{"repo":"o/other","url":"` + alertURL + `","state":"running"}]`},
|
||||
{name: "unsafe link dropped", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"done","link":"javascript:x"}]`, wantState: "done"},
|
||||
{name: "kind filter respected", rows: `[{"repo":"o/r","url":"` + alertURL + `","state":"running"}]`,
|
||||
mutate: func(a *config.Action) { a.Kinds = []string{"pr_waiting"} }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
fh.statBody = tc.rows
|
||||
m := newActionServer(t, tc.mutate, fh.srv.URL).state(t)
|
||||
if _, ok := m["actionsError"]; ok {
|
||||
t.Fatalf("unexpected actionsError: %v", m["actionsError"])
|
||||
}
|
||||
byAction, _ := alertField(m, "workflow_failed", "actionStatus").(map[string]any)
|
||||
st, _ := byAction["rerun"].(map[string]any)
|
||||
if tc.wantState == nil {
|
||||
if st != nil {
|
||||
t.Fatalf("status present: %v", st)
|
||||
}
|
||||
return
|
||||
}
|
||||
if st["state"] != tc.wantState || st["link"] != tc.wantLink {
|
||||
t.Fatalf("status = %v, want state %v link %v", st, tc.wantState, tc.wantLink)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateListsActionsWithoutSecrets(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
rec := do(h.srv.Handler(), "GET", "/api/state")
|
||||
raw := rec.Body.String()
|
||||
for _, secret := range []string{testToken, fh.srv.URL, "/hook", "/status"} {
|
||||
if strings.Contains(raw, secret) {
|
||||
t.Fatalf("state leaks %q", secret)
|
||||
}
|
||||
}
|
||||
var m struct {
|
||||
Actions []map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil || len(m.Actions) != 1 {
|
||||
t.Fatalf("%v %s", err, raw)
|
||||
}
|
||||
a := m.Actions[0]
|
||||
if a["id"] != "rerun" || a["label"] != "Re-run" || a["confirm"] != "Run it?" || len(a["kinds"].([]any)) != 1 {
|
||||
t.Fatalf("action = %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateStatusDown(t *testing.T) {
|
||||
tests := map[string]func(*fakeHook, *string){
|
||||
"server error": func(f *fakeHook, _ *string) { f.statCode = 500 },
|
||||
"invalid json": func(f *fakeHook, _ *string) { f.statBody = "nope" },
|
||||
"unreachable": func(f *fakeHook, base *string) { f.srv.Close(); *base = f.srv.URL },
|
||||
}
|
||||
for name, breakIt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
base := fh.srv.URL
|
||||
breakIt(fh, &base)
|
||||
h := newActionServer(t, nil, base)
|
||||
m := h.state(t)
|
||||
if m["actionsError"] != actionsUnavail {
|
||||
t.Fatalf("actionsError = %v", m["actionsError"])
|
||||
}
|
||||
if alertField(m, "workflow_failed", "actionStatus") != nil {
|
||||
t.Fatal("actionStatus present while status is down")
|
||||
}
|
||||
if len(m["actions"].([]any)) != 1 {
|
||||
t.Fatal("actions must still be listed so the buttons work")
|
||||
}
|
||||
if strings.Contains(h.logs.String(), testToken) {
|
||||
t.Fatal("token leaked into logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateWithoutStatusURL(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, func(a *config.Action) { a.StatusURL = "" }, fh.srv.URL)
|
||||
m := h.state(t)
|
||||
if _, ok := m["actionsError"]; ok || fh.statCalls.Load() != 0 || len(m["actions"].([]any)) != 1 {
|
||||
t.Fatalf("state: %v, status calls %d", m, fh.statCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCachedAndInvalidatedByRequest(t *testing.T) {
|
||||
fh := newFakeHook(t)
|
||||
h := newActionServer(t, nil, fh.srv.URL)
|
||||
h.state(t)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 1 {
|
||||
t.Fatalf("status calls = %d, want 1 inside the TTL", fh.statCalls.Load())
|
||||
}
|
||||
h.clock = h.clock.Add(statusCacheTTL + time.Second)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 2 {
|
||||
t.Fatalf("status calls = %d, want 2 after the TTL", fh.statCalls.Load())
|
||||
}
|
||||
h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil)
|
||||
h.state(t)
|
||||
if fh.statCalls.Load() != 3 {
|
||||
t.Fatalf("status calls = %d, want 3 after a request", fh.statCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectDoesNotForwardToken(t *testing.T) {
|
||||
var leaked atomic.Bool
|
||||
other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
leaked.Store(true)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(other.Close)
|
||||
fh := newFakeHook(t)
|
||||
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
t.Cleanup(hook.Close)
|
||||
h := newActionServer(t, func(a *config.Action) { a.Webhook.URL = hook.URL }, fh.srv.URL)
|
||||
if rec := h.post("/api/actions/rerun", reqBody("o/r", alertURL), nil); rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if leaked.Load() {
|
||||
t.Fatal("bearer token forwarded across a redirect")
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -10,13 +10,15 @@ import (
|
||||
// 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.
|
||||
type apiState struct {
|
||||
Now time.Time `json:"now"`
|
||||
LastRefresh time.Time `json:"lastRefresh"`
|
||||
Now time.Time `json:"now"`
|
||||
ActionsError string `json:"actionsError,omitempty"`
|
||||
Actions []apiAction `json:"actions,omitempty"`
|
||||
Repos []apiRepo `json:"repos"`
|
||||
Alerts []apiAlert `json:"alerts"`
|
||||
Thresholds apiThresholds `json:"thresholds"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
Scan apiScan `json:"scan"`
|
||||
Counts apiCounts `json:"counts"`
|
||||
IntervalSeconds int64 `json:"intervalSeconds"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
@@ -85,13 +87,15 @@ type apiRelease struct {
|
||||
}
|
||||
|
||||
type apiAlert 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"`
|
||||
Since time.Time `json:"since"`
|
||||
ActionStatus map[string]apiActionStatus `json:"actionStatus,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
st.Alerts = append(st.Alerts, apiAlert{
|
||||
Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject,
|
||||
Detail: a.Detail, URL: a.URL, Since: a.Since,
|
||||
Detail: a.Detail, URL: a.URL, Ref: a.Ref, Since: a.Since,
|
||||
})
|
||||
}
|
||||
return st
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
// maxAutoSends caps webhook calls per refresh so a first start cannot flood the consumer.
|
||||
const maxAutoSends = 5
|
||||
|
||||
type autoKey struct{ action, repo, url, ref string }
|
||||
|
||||
// AutoRunner fires auto actions once per (action, repo, url, head commit).
|
||||
type AutoRunner struct {
|
||||
actions *Actions
|
||||
sent map[autoKey]struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewAutoRunner returns a runner for the auto actions in a, or nil when none is configured.
|
||||
func NewAutoRunner(a *Actions) *AutoRunner {
|
||||
for _, rt := range a.list {
|
||||
if rt.cfg.Auto {
|
||||
return &AutoRunner{actions: a, sent: map[autoKey]struct{}{}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run sends the webhooks that are due for the alerts in v and forgets the ones whose alert is gone.
|
||||
func (r *AutoRunner) Run(ctx context.Context, v collector.View) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
live := map[autoKey]struct{}{}
|
||||
budget := maxAutoSends
|
||||
for _, rt := range r.actions.list {
|
||||
if !rt.cfg.Auto {
|
||||
continue
|
||||
}
|
||||
for _, al := range v.Alerts {
|
||||
if !rt.cfg.AppliesTo(al.Kind) || len(rt.cfg.Authors) > 0 && !rt.cfg.AuthorAllowed(al.Author) {
|
||||
continue
|
||||
}
|
||||
k := autoKey{rt.cfg.ID, strings.ToLower(al.Repo), al.URL, al.Ref}
|
||||
live[k] = struct{}{}
|
||||
if _, done := r.sent[k]; done || budget == 0 || ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
budget--
|
||||
if r.send(ctx, rt, al, v) {
|
||||
r.sent[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k := range r.sent {
|
||||
if _, ok := live[k]; !ok {
|
||||
delete(r.sent, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send posts one alert and reports whether it counts as delivered; 409 means busy and is retried.
|
||||
func (r *AutoRunner) send(ctx context.Context, rt *actionRuntime, al model.Alert, v collector.View) bool {
|
||||
a := r.actions
|
||||
status, _, err := a.post(ctx, rt.cfg, newWebhookPayload(rt.cfg.ID, al, v.Now))
|
||||
switch {
|
||||
case err != nil:
|
||||
a.count(rt.cfg.ID, resultAutoFailed)
|
||||
a.log.Warn("auto action webhook failed", "action", rt.cfg.ID, "repo", al.Repo, "err", err)
|
||||
return false
|
||||
case status == http.StatusConflict:
|
||||
a.count(rt.cfg.ID, resultAutoBusy)
|
||||
a.log.Info("auto action busy, will retry", "action", rt.cfg.ID, "repo", al.Repo)
|
||||
return false
|
||||
case status/100 == 2:
|
||||
a.count(rt.cfg.ID, resultAutoOK)
|
||||
rt.invalidate()
|
||||
a.log.Info("auto action sent", "action", rt.cfg.ID, "repo", al.Repo, "ref", al.Ref)
|
||||
return true
|
||||
}
|
||||
result := resultAutoFailed
|
||||
if status/100 == 4 {
|
||||
result = resultAutoRefused
|
||||
}
|
||||
a.count(rt.cfg.ID, result)
|
||||
a.log.Warn("auto action webhook answered", "action", rt.cfg.ID, "repo", al.Repo, "status", status)
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
|
||||
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/config"
|
||||
"github.com/lukaszraczylo/ghtracker/internal/model"
|
||||
)
|
||||
|
||||
func autoPR(n int, sha, author string, mut func(*model.PullRequest)) model.PullRequest {
|
||||
p := model.PullRequest{Number: n, Title: "t", URL: prURL + strings.Repeat("x", n), Author: author, HeadSHA: sha, CreatedAt: now.Add(-time.Hour)}
|
||||
if mut != nil {
|
||||
mut(&p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// autoView builds the view through the real alert evaluation so fork and draft rules are exercised.
|
||||
func autoView(prs ...model.PullRequest) collector.View {
|
||||
r := model.Repo{FullName: "o/r", Up: true, RefreshedAt: now, PRs: prs}
|
||||
alerts := collector.Evaluate(&r, now, config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1})
|
||||
return collector.View{Now: now, Repos: []model.Repo{r}, Alerts: alerts}
|
||||
}
|
||||
|
||||
type autoStep struct {
|
||||
prs []model.PullRequest
|
||||
code int
|
||||
wantPosts int32
|
||||
}
|
||||
|
||||
func TestAutoRunner(t *testing.T) {
|
||||
fork := func(p *model.PullRequest) { p.Fork = true }
|
||||
draft := func(p *model.PullRequest) { p.Draft = true }
|
||||
tests := map[string]struct {
|
||||
authors []string
|
||||
steps []autoStep
|
||||
}{
|
||||
"fork triggers once per sha": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", fork)}, 200, 0},
|
||||
}},
|
||||
"draft skipped": {steps: []autoStep{{[]model.PullRequest{autoPR(1, "a", "x", draft)}, 200, 0}}},
|
||||
"author filter": {authors: []string{"Alice"}, steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "bob", nil), autoPR(2, "b", "alice", nil)}, 200, 1},
|
||||
}},
|
||||
"once per sha": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||
}},
|
||||
"new sha resends": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
{[]model.PullRequest{autoPR(1, "b", "x", nil)}, 201, 1},
|
||||
}},
|
||||
"reappearing alert resends": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
{nil, 200, 0},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
}},
|
||||
"failure retries": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 500, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 400, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||
}},
|
||||
"409 retries": {steps: []autoStep{
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 409, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 1},
|
||||
{[]model.PullRequest{autoPR(1, "a", "x", nil)}, 200, 0},
|
||||
}},
|
||||
"cap per refresh": {steps: []autoStep{
|
||||
{manyPRs(8), 200, maxAutoSends},
|
||||
{manyPRs(8), 200, 3},
|
||||
{manyPRs(8), 200, 0},
|
||||
}},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
hook := newFakeHook(t)
|
||||
a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true, Authors: tc.authors,
|
||||
Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry())
|
||||
runner := NewAutoRunner(a)
|
||||
for i, st := range tc.steps {
|
||||
hook.postCode = st.code
|
||||
before := hook.postCalls.Load()
|
||||
runner.Run(t.Context(), autoView(st.prs...))
|
||||
if got := hook.postCalls.Load() - before; got != st.wantPosts {
|
||||
t.Fatalf("step %d: posts = %d, want %d", i, got, st.wantPosts)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func manyPRs(n int) []model.PullRequest {
|
||||
out := make([]model.PullRequest, n)
|
||||
for i := range out {
|
||||
out[i] = autoPR(i+1, "s", "x", nil)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAutoRunnerPayloadAuthAndMetrics(t *testing.T) {
|
||||
hook := newFakeHook(t)
|
||||
a := NewActions([]config.Action{{ID: "review", Kinds: []string{collector.KindPROpen}, Auto: true,
|
||||
Token: testToken, Webhook: config.Webhook{URL: hook.srv.URL + "/hook", Timeout: 2 * time.Second}}},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry())
|
||||
NewAutoRunner(a).Run(t.Context(), autoView(autoPR(1, "abc", "x", nil)))
|
||||
hook.mu.Lock()
|
||||
defer hook.mu.Unlock()
|
||||
if hook.lastAuth != "Bearer "+testToken {
|
||||
t.Fatalf("auth = %q", hook.lastAuth)
|
||||
}
|
||||
for _, want := range []string{`"action":"review"`, `"kind":"pr_open"`, `"ref":"abc"`, `"url":"` + prURL + `x"`} {
|
||||
if !strings.Contains(hook.lastBody, want) {
|
||||
t.Fatalf("body %s lacks %s", hook.lastBody, want)
|
||||
}
|
||||
}
|
||||
if got := testutil.ToFloat64(a.counter.WithLabelValues("review", resultAutoOK)); got != 1 {
|
||||
t.Fatalf("auto_ok = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAutoRunnerNilWithoutAutoActions(t *testing.T) {
|
||||
a := NewActions([]config.Action{{ID: "x", Webhook: config.Webhook{URL: "http://h.test"}}}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
if NewAutoRunner(a) != nil {
|
||||
t.Fatal("runner must be nil when no action is auto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateCarriesPROpenRef(t *testing.T) {
|
||||
st := buildState(autoView(autoPR(1, "abc", "x", nil)), true)
|
||||
if len(st.Alerts) != 1 || st.Alerts[0].Kind != collector.KindPROpen || st.Alerts[0].Ref != "abc" || st.Alerts[0].Severity != "ok" {
|
||||
t.Fatalf("alerts = %+v", st.Alerts)
|
||||
}
|
||||
if st.Counts.Crit != 0 || st.Counts.Warn != 0 || st.Repos[0].Health != "ok" {
|
||||
t.Fatalf("counts/health changed: %+v %s", st.Counts, st.Repos[0].Health)
|
||||
}
|
||||
}
|
||||
+87
-5
@@ -2,11 +2,14 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -16,6 +19,8 @@ import (
|
||||
|
||||
const (
|
||||
minManualRefreshGap = 5 * time.Minute
|
||||
// repoRefreshCooldown limits each repository separately from the full-refresh gap.
|
||||
repoRefreshCooldown = 60 * time.Second
|
||||
indexFile = "index.html"
|
||||
assetsPrefix = "/assets/"
|
||||
immutableCache = "public, max-age=31536000, immutable"
|
||||
@@ -28,12 +33,15 @@ type Source interface {
|
||||
View() collector.View
|
||||
Stats() collector.Stats
|
||||
RequestRefresh() bool
|
||||
RefreshRepo(ctx context.Context, name string) (collector.View, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
lastManual time.Time
|
||||
lastRepo map[string]time.Time
|
||||
now func() time.Time
|
||||
src Source
|
||||
actions *Actions
|
||||
ui fs.FS
|
||||
metrics http.Handler
|
||||
log *slog.Logger
|
||||
@@ -43,7 +51,8 @@ type Server struct {
|
||||
|
||||
// New builds the server; ui is the built frontend (index.html at its root).
|
||||
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
|
||||
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now,
|
||||
lastRepo: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
@@ -56,6 +65,9 @@ func (s *Server) Handler() http.Handler {
|
||||
if s.manualRefresh {
|
||||
mux.HandleFunc("POST /refresh", s.refresh)
|
||||
}
|
||||
if s.actions != nil {
|
||||
mux.HandleFunc("POST /api/actions/{id}", s.runAction)
|
||||
}
|
||||
return securityHeaders(mux)
|
||||
}
|
||||
|
||||
@@ -91,10 +103,12 @@ func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
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("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)
|
||||
}
|
||||
}
|
||||
@@ -109,11 +123,14 @@ func (s *Server) health(w http.ResponseWriter, _ *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 site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
||||
if crossSite(r) {
|
||||
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if repo := r.URL.Query().Get("repo"); repo != "" {
|
||||
s.refreshRepo(w, r, repo)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
|
||||
if !tooSoon {
|
||||
@@ -127,3 +144,68 @@ func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
s.src.RequestRefresh()
|
||||
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.
|
||||
type repoRefreshResponse struct {
|
||||
Repo string `json:"repo"`
|
||||
apiState
|
||||
}
|
||||
|
||||
// refreshRepo refreshes one repository and answers when it finishes. It has its own cooldown and
|
||||
// never touches the full-refresh gap. A running full refresh or same-repo refresh gives 409.
|
||||
func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string) {
|
||||
key := strings.ToLower(repo)
|
||||
s.mu.Lock()
|
||||
prev, had := s.lastRepo[key]
|
||||
wait := repoRefreshCooldown - s.now().Sub(prev)
|
||||
if had && wait > 0 {
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
|
||||
writeJSONError(w, http.StatusTooManyRequests, "repository refresh allowed once per "+repoRefreshCooldown.String())
|
||||
return
|
||||
}
|
||||
s.lastRepo[key] = s.now()
|
||||
s.mu.Unlock()
|
||||
|
||||
v, err := s.src.RefreshRepo(r.Context(), repo)
|
||||
if err != nil {
|
||||
// A refused or cancelled attempt spent no GitHub budget, so it must not start a cooldown.
|
||||
s.mu.Lock()
|
||||
if had {
|
||||
s.lastRepo[key] = prev
|
||||
} else {
|
||||
delete(s.lastRepo, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
switch {
|
||||
case errors.Is(err, collector.ErrUnknownRepo):
|
||||
writeJSONError(w, http.StatusNotFound, "unknown repository")
|
||||
case errors.Is(err, collector.ErrRefreshBusy):
|
||||
writeJSONError(w, http.StatusConflict, "a refresh is already running; try again shortly")
|
||||
default:
|
||||
s.log.Error("refresh repo", "repo", repo, "err", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "refresh failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
@@ -19,15 +22,34 @@ import (
|
||||
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
type fakeSource struct {
|
||||
repoFn func(name string) (collector.View, error)
|
||||
repoCalls []string
|
||||
st collector.Stats
|
||||
v collector.View
|
||||
refreshed int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *fakeSource) View() collector.View { return f.v }
|
||||
func (f *fakeSource) Stats() collector.Stats { return f.st }
|
||||
func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true }
|
||||
|
||||
func (f *fakeSource) RefreshRepo(_ context.Context, name string) (collector.View, error) {
|
||||
f.mu.Lock()
|
||||
f.repoCalls = append(f.repoCalls, name)
|
||||
f.mu.Unlock()
|
||||
if f.repoFn != nil {
|
||||
return f.repoFn(name)
|
||||
}
|
||||
return f.v, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) calls() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.repoCalls)
|
||||
}
|
||||
|
||||
func testView() collector.View {
|
||||
repo := model.Repo{
|
||||
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
|
||||
@@ -241,3 +263,187 @@ func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
|
||||
t.Fatalf("same-origin: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func post(h http.Handler, target string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("POST", target, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func repoServer(src *fakeSource, manual bool) (*Server, *time.Time) {
|
||||
srv := newServer(src, testUI(), manual)
|
||||
clock := now
|
||||
srv.now = func() time.Time { return clock }
|
||||
return srv, &clock
|
||||
}
|
||||
|
||||
func TestRepoRefreshReturnsStateAndRepoName(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||
}
|
||||
var got struct {
|
||||
Repo string `json:"repo"`
|
||||
Repos []struct {
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"repos"`
|
||||
Counts struct{ Crit int } `json:"counts"`
|
||||
Loaded bool `json:"loaded"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Repo != "o/r" || !got.Loaded || len(got.Repos) != 2 || got.Counts.Crit != 1 {
|
||||
t.Fatalf("body: %s", rec.Body.String())
|
||||
}
|
||||
if len(src.repoCalls) != 1 || src.repoCalls[0] != "o/r" || src.refreshed != 0 {
|
||||
t.Fatalf("calls=%v full=%d", src.repoCalls, src.refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshUnknownRepoIs404JSON(t *testing.T) {
|
||||
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, collector.ErrUnknownRepo }}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
rec := post(h, "/refresh?repo=x/y", nil)
|
||||
var body map[string]string
|
||||
if rec.Code != http.StatusNotFound || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body["error"] == "" {
|
||||
t.Fatalf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=x/y", nil); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("a rejected name must not start a cooldown: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshCooldownPerRepo(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, clock := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first: %d", rec.Code)
|
||||
}
|
||||
*clock = clock.Add(20 * time.Second)
|
||||
rec := post(h, "/refresh?repo=O/R", nil)
|
||||
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "40" {
|
||||
t.Fatalf("cooldown: %d retry-after=%q", rec.Code, rec.Header().Get("Retry-After"))
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("other repo must be independent: %d", rec.Code)
|
||||
}
|
||||
*clock = clock.Add(repoRefreshCooldown)
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK || src.calls() != 3 {
|
||||
t.Fatalf("after cooldown: %d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshDoesNotUseFullRefreshBudget(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("repo: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh", nil); rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("full refresh after repo refresh: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh", nil); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("full refresh gap must still apply: %d", rec.Code)
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/bare", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("repo refresh must not be blocked by the full gap: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshEmptyParamKeepsFullRefresh(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
rec := post(srv.Handler(), "/refresh?repo=", nil)
|
||||
if rec.Code != http.StatusAccepted || rec.Body.Len() != 0 || src.refreshed != 1 || src.calls() != 0 {
|
||||
t.Fatalf("%d body=%q full=%d repo=%d", rec.Code, rec.Body.String(), src.refreshed, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshBusyIs409AndKeepsCooldownFree(t *testing.T) {
|
||||
busy := true
|
||||
src := &fakeSource{v: testView(), repoFn: func(string) (collector.View, error) {
|
||||
if busy {
|
||||
return collector.View{}, collector.ErrRefreshBusy
|
||||
}
|
||||
return testView(), nil
|
||||
}}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("busy: %d", rec.Code)
|
||||
}
|
||||
busy = false
|
||||
if rec := post(h, "/refresh?repo=o/r", nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("retry after busy: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshUnexpectedErrorIs500(t *testing.T) {
|
||||
src := &fakeSource{repoFn: func(string) (collector.View, error) { return collector.View{}, errors.New("x") }}
|
||||
srv, _ := repoServer(src, true)
|
||||
if rec := post(srv.Handler(), "/refresh?repo=o/r", nil); rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshRefusesCrossSite(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
for _, site := range []string{"cross-site", "same-site"} {
|
||||
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": site}); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: %d", site, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := post(h, "/refresh?repo=o/r", map[string]string{"Sec-Fetch-Site": "same-origin"}); rec.Code != http.StatusOK || src.calls() != 1 {
|
||||
t.Fatalf("same-origin: %d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshDisabledByConfig(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, false)
|
||||
rec := post(srv.Handler(), "/refresh?repo=o/r", nil)
|
||||
if rec.Code == http.StatusOK || src.calls() != 0 {
|
||||
t.Fatalf("%d calls=%d", rec.Code, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoRefreshConcurrentRequests(t *testing.T) {
|
||||
src := &fakeSource{v: testView()}
|
||||
srv, _ := repoServer(src, true)
|
||||
h := srv.Handler()
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 16)
|
||||
for range 16 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
codes <- post(h, "/refresh?repo=o/r", nil).Code
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
ok, limited := 0, 0
|
||||
for c := range codes {
|
||||
switch c {
|
||||
case http.StatusOK:
|
||||
ok++
|
||||
case http.StatusTooManyRequests:
|
||||
limited++
|
||||
}
|
||||
}
|
||||
if ok != 1 || limited != 15 || src.calls() != 1 {
|
||||
t.Fatalf("ok=%d limited=%d calls=%d", ok, limited, src.calls())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
|
||||
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}
|
||||
|
||||
+1
-1
@@ -43,5 +43,5 @@
|
||||
"vitest": "^5.0.1",
|
||||
"vue-tsc": "^3.3.11"
|
||||
},
|
||||
"packageManager": "[email protected]1.0"
|
||||
"packageManager": "[email protected]8.3"
|
||||
}
|
||||
|
||||
Generated
+584
-1496
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -89,4 +89,52 @@ describe('App', () => {
|
||||
await flushPromises()
|
||||
expect(off.find('[data-test=rescan]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes one repo from its button, shows the new state and announces it', async () => {
|
||||
const first = state({
|
||||
manualRefresh: true,
|
||||
repos: [repo({ refreshedAt: '2026-10-02T10:00:00Z' })],
|
||||
})
|
||||
const after = { ...first, repo: 'o/r', repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z' })] }
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify(init?.method === 'POST' ? after : first), { status: 200 }),
|
||||
),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const w = mount(App, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(fetchMock).toHaveBeenCalledWith('/refresh?repo=o%2Fr', { method: 'POST' })
|
||||
expect(w.get('[data-test=announcement]').text()).toBe('Refreshed o/r')
|
||||
})
|
||||
|
||||
it('shows the refusal message when a repo is refreshed too soon', async () => {
|
||||
const body = state({ manualRefresh: true, repos: [repo()] })
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((_url: string, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
init?.method === 'POST'
|
||||
? new Response('{}', { status: 429, headers: { 'Retry-After': '30' } })
|
||||
: new Response(JSON.stringify(body), { status: 200 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const w = mount(App, { global: { plugins: [createPinia()] } })
|
||||
await flushPromises()
|
||||
await w.get('[data-test=project] [data-test=repo-refresh]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.get('[data-test=notice]').text()).toBe('o/r was refreshed recently. Try again in 30s')
|
||||
expect(
|
||||
w.get('[data-test=project] [data-test=repo-refresh]').attributes('disabled'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the repo buttons when the server disables manual refresh', async () => {
|
||||
const w = mountWith(state({ manualRefresh: false, repos: [repo()] }))
|
||||
await flushPromises()
|
||||
expect(w.find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+12
@@ -17,6 +17,8 @@ const {
|
||||
state,
|
||||
error,
|
||||
notice,
|
||||
announcement,
|
||||
refreshingRepos,
|
||||
scanning,
|
||||
blocks,
|
||||
visibleBlocks,
|
||||
@@ -91,6 +93,8 @@ onBeforeUnmount(() => {
|
||||
{{ notice }}
|
||||
</p>
|
||||
|
||||
<p class="sr-only" role="status" data-test="announcement">{{ announcement }}</p>
|
||||
|
||||
<LoadingScreen
|
||||
v-if="!state || !state.loaded"
|
||||
:done="state?.scan.done ?? 0"
|
||||
@@ -123,7 +127,11 @@ onBeforeUnmount(() => {
|
||||
:facets="facets"
|
||||
:owners="owners"
|
||||
:active="activeFilters"
|
||||
:manual-refresh="state.manualRefresh"
|
||||
:refreshing="refreshingRepos"
|
||||
:scanning="scanning"
|
||||
@clear="store.clearFilters()"
|
||||
@refresh="store.refreshRepo"
|
||||
/>
|
||||
<aside class="lg:sticky lg:top-6 lg:self-start">
|
||||
<ProjectList
|
||||
@@ -131,7 +139,11 @@ onBeforeUnmount(() => {
|
||||
:total="state.repos.length"
|
||||
:focused="filters.repo"
|
||||
:now-ms="nowMs"
|
||||
:manual-refresh="state.manualRefresh"
|
||||
:refreshing="refreshingRepos"
|
||||
:scanning="scanning"
|
||||
@select="store.toggleRepo"
|
||||
@refresh="store.refreshRepo"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,12 @@ const props = defineProps<{
|
||||
facets: Facets
|
||||
owners: string[]
|
||||
active: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
const filters = defineModel<Filters>('filters', { required: true })
|
||||
defineEmits<{ clear: [] }>()
|
||||
defineEmits<{ clear: []; refresh: [fullName: string] }>()
|
||||
|
||||
const columnCount = useColumns()
|
||||
// A group's height is its header plus its rows, so rows+2 approximates it.
|
||||
@@ -59,6 +62,10 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
|
||||
:block="block"
|
||||
:now-ms="nowMs"
|
||||
:crit-seconds="critSeconds"
|
||||
:manual-refresh="manualRefresh"
|
||||
:refreshing="refreshing"
|
||||
:scanning="scanning"
|
||||
@refresh="(name) => $emit('refresh', name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import AgeMeter from '@/components/AgeMeter.vue'
|
||||
import RowActions from '@/components/RowActions.vue'
|
||||
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
|
||||
import { iconFor } from '@/lib/kinds'
|
||||
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">
|
||||
{{ shorten(row.detail, 120) }}
|
||||
</p>
|
||||
<RowActions v-if="row.actions?.length" :row="row" />
|
||||
</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"
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
|
||||
import { ageSince, isZeroTime } from '@/lib/format'
|
||||
import type { Repo } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
|
||||
defineEmits<{ select: [fullName: string] }>()
|
||||
const props = defineProps<{
|
||||
repos: Repo[]
|
||||
total: number
|
||||
focused: string | null
|
||||
nowMs: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
defineEmits<{ select: [fullName: string]; refresh: [fullName: string] }>()
|
||||
|
||||
/** Rows rendered at first and added each time the list is scrolled near its end. */
|
||||
const PAGE_SIZE = 20
|
||||
@@ -111,6 +120,14 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
|
||||
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
|
||||
>no release</a
|
||||
>
|
||||
<RepoRefreshButton
|
||||
v-if="manualRefresh"
|
||||
:repo="repo.fullName"
|
||||
:busy="refreshing?.includes(repo.fullName) ?? false"
|
||||
:blocked="scanning ?? false"
|
||||
class="-my-1 -mr-1"
|
||||
@refresh="$emit('refresh', repo.fullName)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
import { faTag } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import AttentionRow from '@/components/AttentionRow.vue'
|
||||
import RepoRefreshButton from '@/components/RepoRefreshButton.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Block } from '@/lib/types'
|
||||
|
||||
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
|
||||
defineProps<{
|
||||
block: Block
|
||||
nowMs: number
|
||||
critSeconds: number
|
||||
manualRefresh?: boolean
|
||||
refreshing?: string[]
|
||||
scanning?: boolean
|
||||
}>()
|
||||
defineEmits<{ refresh: [fullName: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,8 +33,15 @@ defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
|
||||
{{ block.repo.release.tag }}
|
||||
</a>
|
||||
</Badge>
|
||||
<span class="text-muted-foreground ml-auto text-xs">
|
||||
<span class="text-muted-foreground ml-auto flex items-center gap-1 text-xs">
|
||||
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
|
||||
<RepoRefreshButton
|
||||
v-if="manualRefresh"
|
||||
:repo="block.repo.fullName"
|
||||
:busy="refreshing?.includes(block.repo.fullName) ?? false"
|
||||
:blocked="scanning ?? false"
|
||||
@refresh="$emit('refresh', block.repo.fullName)"
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
<ul class="divide-border divide-y px-4">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps<{ repo: string; busy: boolean; blocked: boolean }>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground hover:text-link shrink-0"
|
||||
:disabled="busy || blocked"
|
||||
:aria-label="`Refresh ${repo}`"
|
||||
:aria-busy="busy"
|
||||
:title="`Refresh ${repo}`"
|
||||
data-test="repo-refresh"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faArrowsRotate" :spin="busy" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -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,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { actionKey, useTrackerStore } from '@/stores/tracker'
|
||||
import type { ActionDef, ActionStatus, Row } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ row: Row }>()
|
||||
const store = useTrackerStore()
|
||||
|
||||
// `asking` outlives the dialog so the confirm click still knows which action to run.
|
||||
const asking = ref<ActionDef | null>(null)
|
||||
const open = ref(false)
|
||||
|
||||
const keyOf = (a: ActionDef) => actionKey(a.id, props.row.repo, props.row.url)
|
||||
const busy = (a: ActionDef) => store.runningActions.includes(keyOf(a))
|
||||
const statusOf = (a: ActionDef): ActionStatus | undefined =>
|
||||
props.row.actionStatus?.[a.id] ?? store.localStatus[keyOf(a)]
|
||||
|
||||
function run(a: ActionDef): void {
|
||||
void store.runAction(a.id, props.row.repo, props.row.url)
|
||||
}
|
||||
|
||||
function press(a: ActionDef): void {
|
||||
if (!a.confirm) return run(a)
|
||||
asking.value = a
|
||||
open.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2" data-test="row-actions">
|
||||
<template v-for="a in row.actions" :key="a.id">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
:disabled="busy(a)"
|
||||
:aria-busy="busy(a)"
|
||||
:data-test="`action-${a.id}`"
|
||||
@click="press(a)"
|
||||
>
|
||||
<FontAwesomeIcon v-if="busy(a)" :icon="faSpinner" spin aria-hidden="true" />
|
||||
{{ a.label }}
|
||||
</Button>
|
||||
<Badge v-if="busy(a)" variant="secondary" data-test="action-status">Sending</Badge>
|
||||
<Badge v-else-if="statusOf(a)" variant="secondary" data-test="action-status">
|
||||
<a
|
||||
v-if="statusOf(a)?.link"
|
||||
:href="statusOf(a)?.link"
|
||||
class="underline-offset-4 hover:underline"
|
||||
>{{ statusOf(a)?.label || statusOf(a)?.state }}</a
|
||||
>
|
||||
<template v-else>{{ statusOf(a)?.label || statusOf(a)?.state }}</template>
|
||||
</Badge>
|
||||
</template>
|
||||
<AlertDialog v-model:open="open">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ asking?.label }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{{ asking?.confirm }}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction data-test="action-confirm" @click="asking && run(asking)">
|
||||
{{ asking?.label }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -125,6 +125,7 @@ describe('AgeMeter', () => {
|
||||
|
||||
describe('AttentionRow', () => {
|
||||
const row = (over: Partial<Row> = {}): Row => ({
|
||||
repo: 'o/r',
|
||||
kind: 'pr_waiting',
|
||||
subject: '#5 <b>bold</b> title',
|
||||
detail: 'open 9d',
|
||||
@@ -448,3 +449,55 @@ describe('LoadingScreen', () => {
|
||||
expect(w.attributes('aria-live')).toBe('polite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-repository refresh button', () => {
|
||||
const block = buildBlocks(mixedState())[0]
|
||||
const repos = mixedState().repos
|
||||
const group = (extra = {}) =>
|
||||
mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS, ...extra } })
|
||||
const list = (extra = {}) =>
|
||||
mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs, ...extra } })
|
||||
|
||||
it('is hidden unless manual refresh is on', () => {
|
||||
expect(group().find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
expect(list().find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
expect(list({ manualRefresh: false }).find('[data-test=repo-refresh]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders one labelled button per project and one per group header', () => {
|
||||
const l = list({ manualRefresh: true })
|
||||
const buttons = l.findAll('[data-test=project] [data-test=repo-refresh]')
|
||||
expect(buttons.map((b) => b.attributes('aria-label'))).toEqual([
|
||||
'Refresh o/r',
|
||||
'Refresh o/fine',
|
||||
])
|
||||
const g = group({ manualRefresh: true })
|
||||
expect(g.get('header [data-test=repo-refresh]').attributes('aria-label')).toBe('Refresh o/r')
|
||||
})
|
||||
|
||||
it('emits the repo name on click without selecting the project', async () => {
|
||||
const l = list({ manualRefresh: true })
|
||||
await l.findAll('[data-test=repo-refresh]')[1].trigger('click')
|
||||
expect(l.emitted('refresh')).toEqual([['o/fine']])
|
||||
expect(l.emitted('select')).toBeUndefined()
|
||||
const g = group({ manualRefresh: true })
|
||||
await g.get('[data-test=repo-refresh]').trigger('click')
|
||||
expect(g.emitted('refresh')).toEqual([['o/r']])
|
||||
})
|
||||
|
||||
it('disables only the refreshing repo and spins its icon', () => {
|
||||
const l = list({ manualRefresh: true, refreshing: ['o/r'] })
|
||||
const [busy, idle] = l.findAll('[data-test=repo-refresh]')
|
||||
expect(busy.attributes('disabled')).toBeDefined()
|
||||
expect(busy.attributes('aria-busy')).toBe('true')
|
||||
expect(busy.find('svg').classes()).toContain('fa-spin')
|
||||
expect(idle.attributes('disabled')).toBeUndefined()
|
||||
expect(idle.find('svg').classes()).not.toContain('fa-spin')
|
||||
})
|
||||
|
||||
it('disables every button while a full scan runs', () => {
|
||||
const l = list({ manualRefresh: true, scanning: true })
|
||||
for (const b of l.findAll('[data-test=repo-refresh]'))
|
||||
expect(b.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
+38
-1
@@ -1,4 +1,4 @@
|
||||
import type { TrackerState } from './types'
|
||||
import type { ActionStatus, RepoRefreshResult, TrackerState } from './types'
|
||||
|
||||
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
|
||||
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
|
||||
@@ -14,3 +14,40 @@ export async function requestRefresh(): Promise<void> {
|
||||
if (res.status === 429) throw new RescanLimitedError('Rescan is limited to once every 5 minutes')
|
||||
if (!res.ok) throw new Error(`rescan request failed: HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
/** Thrown when one repository was refreshed too recently or a scan is running. */
|
||||
export class RepoRefreshRefusedError extends Error {}
|
||||
|
||||
const REPO_COOLDOWN_FALLBACK_SECONDS = 60
|
||||
|
||||
/** Refreshes one repository and returns the full state after it, plus the repo name. */
|
||||
export async function requestRepoRefresh(fullName: string): Promise<RepoRefreshResult> {
|
||||
const res = await fetch(`/refresh?repo=${encodeURIComponent(fullName)}`, { method: 'POST' })
|
||||
if (res.status === 429) {
|
||||
const wait = Number(res.headers.get('Retry-After')) || REPO_COOLDOWN_FALLBACK_SECONDS
|
||||
throw new RepoRefreshRefusedError(`${fullName} was refreshed recently. Try again in ${wait}s`)
|
||||
}
|
||||
if (res.status === 409)
|
||||
throw new RepoRefreshRefusedError('A refresh is already running. Try again shortly')
|
||||
if (!res.ok) throw new Error(`refresh of ${fullName} failed: HTTP ${res.status}`)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ export interface Repo {
|
||||
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 {
|
||||
repo: string
|
||||
kind: string
|
||||
@@ -32,6 +47,7 @@ export interface Alert {
|
||||
detail: string
|
||||
url: string
|
||||
since: string
|
||||
actionStatus?: Record<string, ActionStatus>
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
@@ -54,16 +70,24 @@ export interface TrackerState {
|
||||
thresholds: Thresholds
|
||||
repos: Repo[]
|
||||
alerts: Alert[]
|
||||
actions?: ActionDef[]
|
||||
actionsError?: string
|
||||
}
|
||||
|
||||
/** Answer to a single-repository refresh: the state after it, plus the repo name. */
|
||||
export type RepoRefreshResult = TrackerState & { repo: string }
|
||||
|
||||
/** One line in a repo block: every alert that targets the same URL, merged. */
|
||||
export interface Row {
|
||||
repo: string
|
||||
kind: string
|
||||
subject: string
|
||||
detail: string
|
||||
url: string
|
||||
since: string
|
||||
severity: Exclude<Severity, 'ok'>
|
||||
actions?: ActionDef[]
|
||||
actionStatus?: Record<string, ActionStatus>
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
|
||||
+140
-4
@@ -44,18 +44,69 @@ describe('buildBlocks', () => {
|
||||
expect(buildBlocks(s)[0].rows.map((r) => r.subject)).toEqual(['crit row', 'warn row'])
|
||||
})
|
||||
|
||||
it('keeps the repo order from the server and ignores alerts for unknown repos', () => {
|
||||
it('keeps the server order for equal ages and ignores alerts for unknown repos', () => {
|
||||
const since = '2026-10-01T00:00:00Z'
|
||||
const s = state({
|
||||
repos: [repo({ fullName: 'o/b' }), repo({ fullName: 'o/a' })],
|
||||
alerts: [
|
||||
alert({ repo: 'o/a', url: '1' }),
|
||||
alert({ repo: 'o/b', url: '2' }),
|
||||
alert({ repo: 'o/ghost', url: '3' }),
|
||||
alert({ repo: 'o/a', url: '1', since }),
|
||||
alert({ repo: 'o/b', url: '2', since }),
|
||||
alert({ repo: 'o/ghost', url: '3', since }),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a'])
|
||||
})
|
||||
|
||||
it('puts the repo with the newest problem first, whatever its kind or severity', () => {
|
||||
const s = state({
|
||||
repos: [
|
||||
repo({ fullName: 'o/old-crit', health: 'crit' }),
|
||||
repo({ fullName: 'o/new-warn', health: 'warn' }),
|
||||
repo({ fullName: 'o/mid', health: 'crit' }),
|
||||
],
|
||||
alerts: [
|
||||
alert({
|
||||
repo: 'o/old-crit',
|
||||
url: 'a',
|
||||
severity: 'crit',
|
||||
kind: 'pr_waiting',
|
||||
since: '2026-05-19T00:00:00Z',
|
||||
}),
|
||||
alert({
|
||||
repo: 'o/new-warn',
|
||||
url: 'b',
|
||||
severity: 'warn',
|
||||
kind: 'workflow_stuck',
|
||||
since: '2026-10-02T12:00:00Z',
|
||||
}),
|
||||
alert({
|
||||
repo: 'o/mid',
|
||||
url: 'c',
|
||||
severity: 'crit',
|
||||
kind: 'workflow_failed',
|
||||
since: '2026-09-20T00:00:00Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual([
|
||||
'o/new-warn',
|
||||
'o/mid',
|
||||
'o/old-crit',
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks a repo by its newest alert, not its oldest', () => {
|
||||
const s = state({
|
||||
repos: [repo({ fullName: 'o/a', health: 'crit' }), repo({ fullName: 'o/b', health: 'crit' })],
|
||||
alerts: [
|
||||
alert({ repo: 'o/a', url: 'a1', since: '2026-01-01T00:00:00Z' }),
|
||||
alert({ repo: 'o/a', url: 'a2', since: '2026-10-02T00:00:00Z' }),
|
||||
alert({ repo: 'o/b', url: 'b1', since: '2026-06-01T00:00:00Z' }),
|
||||
],
|
||||
})
|
||||
expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/a', 'o/b'])
|
||||
})
|
||||
|
||||
it('returns no blocks when everything is healthy', () => {
|
||||
expect(buildBlocks(state({ repos: [repo()] }))).toEqual([])
|
||||
})
|
||||
@@ -219,4 +270,89 @@ describe('tracker store', () => {
|
||||
expect(store.error).toContain('HTTP 500')
|
||||
expect(store.notice).toBe('')
|
||||
})
|
||||
|
||||
describe('refreshRepo', () => {
|
||||
const refreshed = (over = {}) => ({
|
||||
...state({ repos: [repo({ refreshedAt: '2026-10-02T12:00:00Z', ...over })] }),
|
||||
repo: 'o/r',
|
||||
})
|
||||
|
||||
it('posts the repo, swaps the state in and announces it without another fetch', async () => {
|
||||
const calls: string[] = []
|
||||
mockFetch((url, init) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${url}`)
|
||||
return json(refreshed())
|
||||
})
|
||||
const store = useTrackerStore()
|
||||
const done = store.refreshRepo('o/r')
|
||||
expect(store.refreshingRepos).toEqual(['o/r'])
|
||||
await done
|
||||
expect(calls).toEqual(['POST /refresh?repo=o%2Fr'])
|
||||
expect(store.refreshingRepos).toEqual([])
|
||||
expect(store.state?.repos[0].refreshedAt).toBe('2026-10-02T12:00:00Z')
|
||||
expect(store.announcement).toBe('Refreshed o/r')
|
||||
expect(store.notice).toBe('')
|
||||
})
|
||||
|
||||
it('ignores a second request for the same repo while one is pending', async () => {
|
||||
const fetchMock = mockFetch(() => json(refreshed()))
|
||||
const store = useTrackerStore()
|
||||
await Promise.all([store.refreshRepo('o/r'), store.refreshRepo('o/r')])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the wait from Retry-After on 429', async () => {
|
||||
mockFetch(() => new Response('{}', { status: 429, headers: { 'Retry-After': '42' } }))
|
||||
const store = useTrackerStore()
|
||||
await store.refreshRepo('o/r')
|
||||
expect(store.notice).toBe('o/r was refreshed recently. Try again in 42s')
|
||||
expect(store.refreshingRepos).toEqual([])
|
||||
expect(store.error).toBe('')
|
||||
})
|
||||
|
||||
it('says a scan is running on 409', async () => {
|
||||
mockFetch(() => new Response('{}', { status: 409 }))
|
||||
const store = useTrackerStore()
|
||||
await store.refreshRepo('o/r')
|
||||
expect(store.notice).toContain('already running')
|
||||
})
|
||||
|
||||
it('reports other failures and a repo that came back down', async () => {
|
||||
mockFetch(() => new Response('{}', { status: 500 }))
|
||||
const store = useTrackerStore()
|
||||
await store.refreshRepo('o/r')
|
||||
expect(store.notice).toContain('HTTP 500')
|
||||
mockFetch(() => json(refreshed({ up: false, error: 'boom' })))
|
||||
await store.refreshRepo('o/r')
|
||||
expect(store.notice).toBe('Could not refresh o/r: boom')
|
||||
expect(store.announcement).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
+78
-8
@@ -1,6 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api'
|
||||
import {
|
||||
fetchState,
|
||||
RescanLimitedError,
|
||||
requestAction,
|
||||
requestRefresh,
|
||||
requestRepoRefresh,
|
||||
} from '@/lib/api'
|
||||
import {
|
||||
activeFilterCount,
|
||||
applyFilters,
|
||||
@@ -11,7 +17,7 @@ import {
|
||||
type Filters,
|
||||
} from '@/lib/filters'
|
||||
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. */
|
||||
export const SCAN_POLL_MS = 2000
|
||||
@@ -19,7 +25,12 @@ const SCAN_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const
|
||||
|
||||
/** Groups alerts into one row per target URL and one block per unhealthy repo. */
|
||||
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. */
|
||||
export function buildBlocks(state: TrackerState): Block[] {
|
||||
const rows = new Map<string, Row>()
|
||||
const order = new Map<string, string[]>()
|
||||
@@ -32,9 +43,15 @@ export function buildBlocks(state: TrackerState): Block[] {
|
||||
if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) {
|
||||
existing.severity = alert.severity
|
||||
}
|
||||
if (sinceMs(alert.since) > sinceMs(existing.since)) existing.since = alert.since
|
||||
continue
|
||||
}
|
||||
const actions = (state.actions ?? []).filter(
|
||||
(a) => a.kinds.length === 0 || a.kinds.includes(alert.kind),
|
||||
)
|
||||
rows.set(key, {
|
||||
repo: alert.repo,
|
||||
...(actions.length > 0 && { actions, actionStatus: alert.actionStatus }),
|
||||
kind: alert.kind,
|
||||
subject: alert.subject,
|
||||
detail: alert.detail,
|
||||
@@ -52,7 +69,8 @@ export function buildBlocks(state: TrackerState): Block[] {
|
||||
list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
|
||||
blocks.push({ repo, rows: list })
|
||||
}
|
||||
return blocks
|
||||
const newest = (b: Block) => Math.max(...b.rows.map((r) => sinceMs(r.since)))
|
||||
return blocks.sort((a, b) => newest(b) - newest(a))
|
||||
}
|
||||
|
||||
export interface RepoCounts {
|
||||
@@ -98,6 +116,13 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
const loading = ref(false)
|
||||
const requesting = ref(false)
|
||||
const notice = ref('')
|
||||
/** Screen-reader text for a finished single-repository refresh. */
|
||||
const announcement = ref('')
|
||||
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 tick = ref(Date.now())
|
||||
|
||||
@@ -143,13 +168,16 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
tick.value = Date.now()
|
||||
}
|
||||
|
||||
function applyState(next: TrackerState): void {
|
||||
skewMs.value = Date.parse(next.now) - Date.now()
|
||||
tick.value = Date.now()
|
||||
state.value = next
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const next = await fetchState()
|
||||
skewMs.value = Date.parse(next.now) - Date.now()
|
||||
tick.value = Date.now()
|
||||
state.value = next
|
||||
applyState(await fetchState())
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
@@ -180,11 +208,52 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Refreshes one repository; the answer carries the new state, so no extra fetch follows. */
|
||||
async function refreshRepo(fullName: string): Promise<void> {
|
||||
if (refreshingRepos.value.includes(fullName)) return
|
||||
refreshingRepos.value = [...refreshingRepos.value, fullName]
|
||||
notice.value = ''
|
||||
announcement.value = ''
|
||||
try {
|
||||
const next = await requestRepoRefresh(fullName)
|
||||
applyState(next)
|
||||
const failed = next.repos.find((r) => r.fullName === fullName)?.error
|
||||
if (failed) notice.value = `Could not refresh ${fullName}: ${failed}`
|
||||
else announcement.value = `Refreshed ${fullName}`
|
||||
} catch (e) {
|
||||
notice.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
refreshingRepos.value = refreshingRepos.value.filter((n) => n !== fullName)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
state,
|
||||
error,
|
||||
runningActions,
|
||||
localStatus,
|
||||
runAction,
|
||||
loading,
|
||||
notice,
|
||||
announcement,
|
||||
refreshingRepos,
|
||||
scanning,
|
||||
nowMs,
|
||||
blocks,
|
||||
@@ -201,6 +270,7 @@ export const useTrackerStore = defineStore('tracker', () => {
|
||||
clearFilters,
|
||||
load,
|
||||
rescan,
|
||||
refreshRepo,
|
||||
advanceClock,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user