feat: add GitHub repository dashboard with Prometheus metrics

ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics.

The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary.

The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
This commit is contained in:
2026-10-02 04:28:52 +01:00
commit 5313b56774
110 changed files with 10967 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
ghtracker
*.pem
config.yaml
deploy
node_modules
dist
coverage
+30
View File
@@ -0,0 +1,30 @@
name: Pull request checks
on:
pull_request:
permissions:
contents: read
pull-requests: write
security-events: write
jobs:
go:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: '>=1.24'
secrets: inherit
ui:
name: UI checks
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npx --yes [email protected] install --frozen-lockfile
- run: npx --yes [email protected] lint
- run: npx --yes [email protected] typecheck
- run: npx --yes [email protected] test
+23
View File
@@ -0,0 +1,23 @@
name: Test, build, release
on:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- '**.md'
- '.github/workflows/ci.yaml'
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: '>=1.24'
docker-enabled: true
secrets: inherit
+8
View File
@@ -0,0 +1,8 @@
/ghtracker
config.yaml
*.pem
node_modules
coverage
dist/*
!dist/.gitkeep
release-out
+88
View File
@@ -0,0 +1,88 @@
version: 2
# GoReleaser cleans its output directory on `--clean`. The UI build lives in dist/,
# so release output goes elsewhere.
dist: release-out
builds:
- id: ghtracker
main: .
binary: ghtracker
env:
- CGO_ENABLED=0
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
flags: [-trimpath]
ldflags:
- -s -w
- -X main.version={{ .Version }}
archives:
- id: ghtracker
formats: [tar.gz]
name_template: 'ghtracker-{{ .Os }}-{{ .Arch }}'
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- config.example.yaml
checksum:
name_template: 'ghtracker-checksums.txt'
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^ci:'
- '^chore:'
- '^style:'
- '^Merge'
release:
github:
owner: lukaszraczylo
name: ghtracker
name_template: 'v{{ .Version }}'
prerelease: auto
dockers_v2:
- images:
- ghcr.io/lukaszraczylo/ghtracker
tags:
- '{{ .Version }}'
- latest
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
labels:
org.opencontainers.image.source: https://github.com/lukaszraczylo/ghtracker
org.opencontainers.image.description: Dashboard and Prometheus metrics for GitHub repositories
org.opencontainers.image.licenses: MIT
org.opencontainers.image.version: '{{ .Version }}'
signs:
- cmd: cosign
signature: '${artifact}.sigstore.json'
args:
- sign-blob
- '--bundle=${signature}'
- '${artifact}'
- '--yes'
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: manifests
output: true
args:
- sign
- '${artifact}@${digest}'
- '--yes'
+1
View File
@@ -0,0 +1 @@
@fortawesome:registry=https://registry.npmjs.org/
+1
View File
@@ -0,0 +1 @@
{ "semi": false, "singleQuote": true, "printWidth": 100 }
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS ui
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
COPY index.html vite.config.ts tsconfig.json ./
COPY scripts ./scripts
COPY src ./src
RUN pnpm build
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui /app/dist ./dist
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /ghtracker .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /ghtracker /ghtracker
EXPOSE 8080
ENTRYPOINT ["/ghtracker", "-config", "/config/config.yaml"]
+5
View File
@@ -0,0 +1,5 @@
FROM gcr.io/distroless/static-debian12:nonroot
ARG TARGETPLATFORM
COPY ${TARGETPLATFORM}/ghtracker /ghtracker
EXPOSE 8080
ENTRYPOINT ["/ghtracker", "-config", "/config/config.yaml"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
.PHONY: ui build test lint dev
ui:
pnpm install --frozen-lockfile
pnpm build
build: ui
go build -o ghtracker .
test:
pnpm test
go test -race ./...
lint:
pnpm lint
pnpm typecheck
golangci-lint run ./...
# Run the Go server (config.yaml) and the Vite dev server with hot reload; open http://localhost:5173.
dev:
go run . -config config.yaml & pnpm dev
+180
View File
@@ -0,0 +1,180 @@
# ghtracker
Dashboard and Prometheus metrics for a list of GitHub repositories. The program authenticates as a GitHub App and refreshes every 6 hours by default.
## Install
Each release publishes binaries for Linux, macOS and Windows on the [releases page](https://github.com/lukaszraczylo/ghtracker/releases), and a multi-architecture container image:
```sh
docker pull ghcr.io/lukaszraczylo/ghtracker:latest
```
Release archives and the image checksums are signed with cosign.
## What it shows
The dashboard lists only what needs attention, grouped by project:
- Failing workflows, including scheduled and other background ones, with a link to the run.
- Pull requests that wait too long or fail their checks, with links and age.
- Stale issues, as one line per project.
- Projects that failed to refresh, or loaded only in part.
A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Healthy items do not appear anywhere else.
### Search and filters
- Type in the search box, or press `/`, to match projects, versions, item titles and details. Every word must match. Press `Esc` to clear.
- Filter by severity, by kind (workflows, pull requests, issues, data problems) and by owner. Each chip shows how many items it would leave, given the other filters, and chips that would match nothing are disabled.
- Select a segment of the health strip or a project name to focus one project.
- The URL holds the filters (`?q=bump&kind=pr&owner=lukaszraczylo`), so you can bookmark or share a filtered view.
## Setup
1. Create a GitHub App with read-only repository permissions: Metadata, Issues, Pull requests, Actions, Checks, Commit statuses and Contents.
2. Install the App on the repositories you want to observe.
3. Generate a private key for the App.
4. Copy `config.example.yaml` to `config.yaml`, then set `github.app_id` and the key path. You can set `GHTRACKER_PRIVATE_KEY` to the PEM contents instead of a file.
5. Run `make build`, then `./ghtracker -config config.yaml`, and open http://localhost:8080.
## Alert rules
| Condition | Severity |
| ---------------------------------------------------------------------------------------- | -------- |
| Latest completed default-branch run of a workflow failed (includes background workflows) | crit |
| Non-draft PR open longer than `pr_crit_after` | crit |
| Repo refresh failed (the dashboard keeps the last good data) | crit |
| Non-draft PR open longer than `pr_warn_after` | warn |
| PR checks failing | warn |
| Workflow queued or running longer than `workflow_stuck_after` | warn |
| Open issues without activity for `issue_stale_after` (one alert per repo) | warn |
| A section failed to load, for example a missing App permission | warn |
| Data older than `data_stale_after` | warn |
Alerts are evaluated when you read them, so PR ages keep advancing between refreshes. Set a threshold to a negative value to disable it. Draft PRs and archived repos produce no work alerts.
## Endpoints
- `/` dashboard.
- `/metrics` Prometheus metrics.
- `/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.
## Metrics
All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time.
| Metric | Labels |
| -------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo |
| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo |
| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo |
| `pr_age_seconds`, `pr_checks_failing` | repo, number |
| `workflow_failing`, `workflow_running`, `workflow_last_run_timestamp_seconds` | repo, workflow, background |
| `release_info`, `release_published_timestamp_seconds` | repo, version, url |
| `alerts` | severity |
| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none |
Example alert rules:
```yaml
- alert: GhtrackerWorkflowFailing
expr: ghtracker_workflow_failing == 1
for: 10m
- alert: GhtrackerPRWaiting
expr: ghtracker_pr_age_seconds > 3 * 86400
- alert: GhtrackerRepoDown
expr: ghtracker_repo_up == 0
for: 30m
- alert: GhtrackerStale
expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600
- alert: GhtrackerRateLimitLow
expr: ghtracker_github_rate_limit_remaining < 500
```
## GitHub rate limits
The program stays well below the limits of an installation token (at least 5,000 requests per hour):
- One refresh costs about 6 requests per repo, plus 2 per open PR and 1 per active workflow. The default interval is 6 hours.
- Conditional requests with `ETag` make unchanged data free: a 304 reply does not count against the primary limit.
- Concurrency defaults to 2 repos at a time. Pagination stops at `max_items`.
- Requests stop while fewer than 100 remain in the window, until the window resets.
- On a primary or secondary limit response, the client honours `Retry-After` or the reset time, and the current refresh stops. It does not retry. Repos keep their last good data.
- Transient 5xx errors retry twice with a short back-off. Other errors do not retry.
- `refresh_interval` has a one-minute minimum.
## Limits of the workflow view
The program lists default-branch runs only. Runs of a workflow that never ran on the default branch do not appear. Pull request runs appear as PR checks.
## Docker
Run the published image, or build your own with the commands below.
```sh
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghcr.io/lukaszraczylo/ghtracker:latest
```
To build the image yourself:
```sh
docker build -t ghtracker .
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \
ghtracker
```
The image is one static binary, with the UI embedded, on `distroless/static:nonroot`. The config must set `listen: ":8080"` so the port is reachable from outside the container. Build and push the image yourself, then set it in `deploy/kubernetes/deployment.yaml`.
## Kubernetes
`deploy/kubernetes/` holds a Kustomize base:
| File | Purpose |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `externalsecret.yaml` | Reads the App private key from your secret store through an External Secrets `ClusterSecretStore`. |
| `configmap.yaml` | The config file, with the App ID, installation ID and repo list. |
| `deployment.yaml` | One replica, non-root, read-only root filesystem, `GHTRACKER_PRIVATE_KEY` from the secret. |
| `service.yaml`, `ingressroute.yaml` | Service on port 80 and a Traefik route. Replace the host. |
| `servicemonitor.yaml`, `prometheusrule.yaml` | Scrape every 5 minutes and five starter alerts. |
```sh
kustomize build deploy/kubernetes | kubectl diff -f -
```
Commit the manifests to the GitOps repo and let ArgoCD sync them. Do not use `kubectl apply`.
Keep the replica count at 1. Each replica refreshes on its own and spends GitHub API budget. The dashboard has no authentication, so keep the route on an internal entrypoint or add an auth middleware.
## Frontend
The UI is a Vue 3 single-page app in `src/`: Vite, Pinia, Tailwind CSS 4, shadcn-vue components and Font Awesome icons. `pnpm build` writes it to `dist/`, and the Go binary embeds that directory with `go:embed`, so the result is one file.
The server returns `503 UI is not built` at `/` until you run `pnpm build`, because `go build` embeds whatever `dist/` holds. `make build` runs both steps.
The UI reads one JSON document from `/api/state` every 60 seconds. The Go side evaluates alert rules, and the UI only groups and displays them. Ages advance in the browser between polls.
## Releases
Every push to `main` runs the shared release workflow from [`lukaszraczylo/shared-actions`](https://github.com/lukaszraczylo/shared-actions). It runs the tests, calculates the version from the commit messages with `semver-generator`, then runs GoReleaser. The result is a GitHub release with binaries and checksums, and a container image on `ghcr.io`.
`semver.yaml` sets the rules: `fix`, `perf`, `refactor` and other maintenance types bump the patch number, `feat` bumps the minor number, and a `Semver-Major:` trailer line is the only way to start a major release.
`workflow-prepare.sh` builds the UI before the Go build, because the binary embeds `dist/`.
## Development
```sh
make dev # Go server on :8080 plus Vite with hot reload on :5173
make test # vitest and go test -race
make lint # eslint, vue-tsc and golangci-lint
```
The Dockerfile builds the UI in a Node stage, then builds the Go binary with the result.
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+23
View File
@@ -0,0 +1,23 @@
listen: ':8080'
refresh_interval: 6h # minimum 1m; each cycle costs roughly 6 + 2 per open PR requests per repo
manual_refresh: true # the Refresh button and POST /refresh, limited to once per 5m; false disables them
github:
app_id: 123456
private_key_file: /secrets/ghtracker.pem # or set $GHTRACKER_PRIVATE_KEY to the PEM contents
installation_id: 0 # 0 = discover per repo; needed when repos span several owners
api_url: https://api.github.com
thresholds: # negative disables a check
pr_warn_after: 48h # non-draft PR open this long = warning
pr_crit_after: 168h # critical
issue_stale_after: 720h # no activity this long = warning (aggregated per repo)
workflow_stuck_after: 6h # queued / in progress this long = warning
max_items: 100 # per repo, issues and PRs
concurrency: 2
repos:
- lukaszraczylo/kubemirror
- lukaszraczylo/semver-generator
- lukaszraczylo/traefikoidc
+20
View File
@@ -0,0 +1,20 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ghtracker
data:
config.yaml: |
listen: ":8080"
refresh_interval: 6h
github:
app_id: 123456 # your GitHub App ID
installation_id: 0
thresholds:
pr_warn_after: 48h
pr_crit_after: 168h
issue_stale_after: 720h
workflow_stuck_after: 6h
repos:
- lukaszraczylo/kubemirror
- lukaszraczylo/semver-generator
- lukaszraczylo/traefikoidc
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ghtracker
spec:
replicas: 1 # one replica: each replica refreshes on its own and spends GitHub API budget
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: ghtracker
template:
metadata:
labels:
app.kubernetes.io/name: ghtracker
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: ghtracker
image: ghcr.io/lukaszraczylo/ghtracker:latest # replace with the image you push
args: ['-config', '/config/config.yaml']
envFrom:
- secretRef:
name: ghtracker-github-app
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
livenessProbe:
httpGet: { path: /metrics, port: http }
periodSeconds: 30
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
volumeMounts:
- name: config
mountPath: /config
readOnly: true
volumes:
- name: config
configMap:
name: ghtracker
+16
View File
@@ -0,0 +1,16 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ghtracker-github-app
spec:
refreshInterval: 24h
secretStoreRef:
name: secrets # your ClusterSecretStore
kind: ClusterSecretStore
target:
name: ghtracker-github-app
creationPolicy: Owner
data:
- secretKey: GHTRACKER_PRIVATE_KEY
remoteRef:
key: ghtracker/app_private_key # where you keep the App private key
+16
View File
@@ -0,0 +1,16 @@
# Replace the host. The dashboard has no authentication: keep it on an internal entrypoint
# or add a Traefik auth middleware before exposing it.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: ghtracker
spec:
entryPoints:
- websecure
routes:
- match: Host(`ghtracker.example.com`)
kind: Rule
services:
- name: ghtracker
port: 80
tls: {}
+12
View File
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ghtracker
resources:
- namespace.yaml
- externalsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- servicemonitor.yaml
- prometheusrule.yaml
- ingressroute.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: ghtracker
+35
View File
@@ -0,0 +1,35 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: ghtracker
spec:
groups:
- name: ghtracker
rules:
- alert: GhtrackerWorkflowFailing
expr: ghtracker_workflow_failing == 1
for: 10m
labels: { severity: warning }
annotations:
summary: 'Workflow {{ $labels.workflow }} failing in {{ $labels.repo }}'
- alert: GhtrackerPullRequestWaiting
expr: ghtracker_pr_age_seconds > 3 * 86400
labels: { severity: warning }
annotations:
summary: 'PR #{{ $labels.number }} in {{ $labels.repo }} waiting over 3 days'
- alert: GhtrackerRepoDown
expr: ghtracker_repo_up == 0
for: 30m
labels: { severity: warning }
annotations:
summary: 'ghtracker cannot refresh {{ $labels.repo }}'
- alert: GhtrackerDataStale
expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600
labels: { severity: warning }
annotations:
summary: 'ghtracker has not refreshed for over 13 hours'
- alert: GhtrackerRateLimitLow
expr: ghtracker_github_rate_limit_remaining < 500
labels: { severity: warning }
annotations:
summary: 'GitHub rate limit budget below 500'
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: ghtracker
labels:
app.kubernetes.io/name: ghtracker
spec:
selector:
app.kubernetes.io/name: ghtracker
ports:
- name: http
port: 80
targetPort: http
+12
View File
@@ -0,0 +1,12 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: ghtracker
spec:
selector:
matchLabels:
app.kubernetes.io/name: ghtracker
endpoints:
- port: http
path: /metrics
interval: 5m # series are computed from cached data; scraping faster adds nothing
View File
+30
View File
@@ -0,0 +1,30 @@
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'coverage', 'internal', 'deploy'] },
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: { parserOptions: { parser: tseslint.parser } },
},
{
files: ['**/*.{ts,vue,js}'],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
'vue/multi-word-component-names': 'off',
// Prettier owns formatting; shadcn-vue components also omit prop defaults on purpose.
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/html-self-closing': 'off',
'vue/html-indent': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/require-default-prop': 'off',
},
},
)
+20
View File
@@ -0,0 +1,20 @@
module github.com/lukaszraczylo/ghtracker
go 1.27.1
require (
github.com/prometheus/client_golang v1.24.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
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
)
+38
View File
@@ -0,0 +1,38 @@
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=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/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=
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=
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=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ghtracker</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+220
View File
@@ -0,0 +1,220 @@
// Package collector refreshes repository data on a schedule and evaluates health from it.
package collector
import (
"context"
"errors"
"log/slog"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const repoFetchTimeout = 5 * time.Minute
// Fetcher loads the data for one "owner/name" repository.
type Fetcher interface {
FetchRepo(ctx context.Context, full string) (model.Repo, error)
}
// View is a consistent, health-evaluated read of the collector state.
type View struct {
Now time.Time
LastRefresh time.Time
Repos []model.Repo
Alerts []model.Alert
Thresholds config.Thresholds
Interval time.Duration
Crit int
Warn int
Loaded bool
Refreshing bool
}
// Stats are monotonic counters and timings of the refresh loop.
type Stats struct {
LastRefresh time.Time
LastDuration time.Duration
RefreshTotal uint64
RepoErrorsTotal uint64
Loaded bool
RefreshInFlight bool
}
type Collector struct {
lastRefresh time.Time
fetch Fetcher
repos map[string]model.Repo
now func() time.Time
log *slog.Logger
kick chan struct{}
names []string
th config.Thresholds
interval time.Duration
refreshes atomic.Uint64
repoErrs atomic.Uint64
lastDur time.Duration
concurrency int
mu sync.RWMutex
limited atomic.Bool
refreshing atomic.Bool
loaded bool
}
func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector {
return &Collector{
fetch: f,
names: cfg.Repos,
th: cfg.Thresholds,
interval: cfg.RefreshInterval,
concurrency: cfg.Concurrency,
now: time.Now,
log: log,
kick: make(chan struct{}, 1),
repos: make(map[string]model.Repo, len(cfg.Repos)),
}
}
// Run refreshes immediately, then on every interval or kick, until ctx ends.
func (c *Collector) Run(ctx context.Context) {
t := time.NewTicker(c.interval)
defer t.Stop()
c.Refresh(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.Refresh(ctx)
case <-c.kick:
c.Refresh(ctx)
}
}
}
// 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() {
return false
}
select {
case c.kick <- struct{}{}:
return true
default:
return false
}
}
// Refresh fetches every repo once; a failing repo keeps its last good data and is marked down.
func (c *Collector) Refresh(ctx context.Context) {
if !c.refreshing.CompareAndSwap(false, true) {
return
}
defer c.refreshing.Store(false)
start := c.now()
c.limited.Store(false)
results := make([]model.Repo, len(c.names))
sem := make(chan struct{}, c.concurrency)
var wg sync.WaitGroup
for i, name := range c.names {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
results[i] = c.refreshOne(ctx, name, start)
}()
}
wg.Wait()
c.mu.Lock()
for _, r := range results {
c.repos[r.FullName] = r
}
c.lastRefresh = start
c.lastDur = c.now().Sub(start)
c.loaded = true
c.mu.Unlock()
c.refreshes.Add(1)
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()
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 errors.Is(err, model.ErrRateLimited) {
c.limited.Store(true)
}
if err == nil {
r.FullName, r.Up, r.RefreshedAt = name, true, at
return r
}
c.repoErrs.Add(1)
c.log.Error("repo refresh failed", "repo", name, "err", err)
c.mu.RLock()
prev, ok := c.repos[name]
c.mu.RUnlock()
if !ok {
prev = model.Repo{FullName: name, URL: "https://github.com/" + name}
}
prev.Up, prev.Err = false, err.Error()
return prev
}
// Stats returns refresh-loop counters.
func (c *Collector) Stats() Stats {
c.mu.RLock()
defer c.mu.RUnlock()
return Stats{
LastRefresh: c.lastRefresh,
LastDuration: c.lastDur,
RefreshTotal: c.refreshes.Load(),
RepoErrorsTotal: c.repoErrs.Load(),
Loaded: c.loaded,
RefreshInFlight: c.refreshing.Load(),
}
}
// View evaluates all repos at now. Repos not yet fetched appear with no data and are not alerted on.
func (c *Collector) View() View {
now := c.now()
c.mu.RLock()
v := View{
Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th,
Loaded: c.loaded, Refreshing: c.refreshing.Load(),
}
v.Repos = make([]model.Repo, 0, len(c.names))
for _, n := range c.names {
if r, ok := c.repos[n]; ok {
v.Repos = append(v.Repos, r)
}
}
c.mu.RUnlock()
for i := range v.Repos {
v.Alerts = append(v.Alerts, Evaluate(&v.Repos[i], now, c.th)...)
}
sortAlerts(v.Alerts)
for _, a := range v.Alerts {
switch a.Severity {
case model.Crit:
v.Crit++
case model.Warn:
v.Warn++
}
}
// Worst repos first so problems surface at the top of the dashboard.
slices.SortStableFunc(v.Repos, func(a, b model.Repo) int { return int(b.Health) - int(a.Health) })
return v
}
+314
View File
@@ -0,0 +1,314 @@
package collector
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
func th() config.Thresholds {
return config.Thresholds{
PRWarnAfter: 48 * time.Hour, PRCritAfter: 7 * 24 * time.Hour,
IssueStaleAfter: 30 * 24 * time.Hour, WorkflowStuck: 6 * time.Hour, WorkflowDormant: 30 * 24 * time.Hour, DataStaleAfter: 13 * time.Hour,
}
}
func kinds(a []model.Alert) map[string]model.Severity {
m := map[string]model.Severity{}
for _, x := range a {
m[x.Kind] = max(m[x.Kind], x.Severity)
}
return m
}
func TestEvaluate(t *testing.T) {
fresh := func() model.Repo {
return model.Repo{FullName: "o/r", URL: "https://github.com/o/r", Up: true, RefreshedAt: t0}
}
failedRun := &model.Run{Status: "completed", Conclusion: "failure", URL: "run"}
tests := map[string]struct {
mutate func(*model.Repo)
want map[string]model.Severity
health model.Severity
}{
"healthy": {func(*model.Repo) {}, map[string]model.Severity{}, model.OK},
"refresh failed": {func(r *model.Repo) { r.Err = "boom"; r.Up = false }, map[string]model.Severity{KindRefreshFailed: model.Crit}, model.Crit},
"data stale": {func(r *model.Repo) { r.RefreshedAt = t0.Add(-14 * time.Hour) }, map[string]model.Severity{KindDataStale: model.Warn}, model.Warn},
"partial data": {func(r *model.Repo) { r.Warnings = []string{"workflows: 403"} }, map[string]model.Severity{KindPartialData: model.Warn}, model.Warn},
"workflow failed": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: *failedRun, LastDone: failedRun}}
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
"workflow rerun in progress after failure still critical": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "in_progress", CreatedAt: t0}, LastDone: failedRun}}
}, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit},
"dormant workflow failure muted": {func(r *model.Repo) {
old := &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: t0.Add(-40 * 24 * time.Hour)}
r.Workflows = []model.Workflow{{Name: "Old", Latest: *old, LastDone: old}}
}, map[string]model.Severity{}, model.OK},
"workflow success": {func(r *model.Repo) {
ok := &model.Run{Status: "completed", Conclusion: "success"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *ok, LastDone: ok}}
}, map[string]model.Severity{}, model.OK},
"cancelled is not failure": {func(r *model.Repo) {
c := &model.Run{Status: "completed", Conclusion: "cancelled"}
r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}}
}, map[string]model.Severity{}, model.OK},
"workflow stuck": {func(r *model.Repo) {
r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}}
}, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn},
"pr fresh": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}}
}, map[string]model.Severity{}, model.OK},
"pr waiting warn": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, 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)}}
}, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit},
"draft pr ignored": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}}
}, map[string]model.Severity{}, model.OK},
"pr checks failing": {func(r *model.Repo) {
r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}}
}, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn},
"issues stale aggregated": {func(r *model.Repo) {
old := t0.Add(-40 * 24 * time.Hour)
r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}}
}, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn},
"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)}}
}, 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())
got := kinds(alerts)
if len(got) != len(tt.want) {
t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want)
}
for k, sev := range tt.want {
if got[k] != sev {
t.Fatalf("kind %s severity = %v, want %v", k, got[k], sev)
}
}
if r.Health != tt.health {
t.Fatalf("health = %v, want %v", r.Health, tt.health)
}
})
}
}
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)}},
Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}}
off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1}
if a := Evaluate(&r, t0, off); len(a) != 0 {
t.Fatalf("disabled checks produced %+v", a)
}
}
func TestAgeAdvancesWithoutRefresh(t *testing.T) {
r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}}
cfg := th()
cfg.DataStaleAfter = -1
if a := Evaluate(&r, t0, cfg); len(a) != 0 {
t.Fatalf("unexpected %+v", a)
}
if a := 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)
}
}
func TestAge(t *testing.T) {
cases := map[time.Duration]string{-time.Minute: "0m", 5 * time.Minute: "5m", 90 * time.Minute: "1h", 47 * time.Hour: "47h", 49 * time.Hour: "2d", 10 * 24 * time.Hour: "10d"}
for d, want := range cases {
if got := Age(d); got != want {
t.Errorf("Age(%s) = %s, want %s", d, got, want)
}
}
}
type fakeFetcher struct {
fn func(name string) (model.Repo, error)
calls []string
mu sync.Mutex
}
func (f *fakeFetcher) FetchRepo(_ context.Context, name string) (model.Repo, error) {
f.mu.Lock()
f.calls = append(f.calls, name)
f.mu.Unlock()
return f.fn(name)
}
func newCollector(f Fetcher, repos ...string) *Collector {
cfg := &config.Config{Repos: repos, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th()}
c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.now = func() time.Time { return t0 }
return c
}
func TestRefreshKeepsLastGoodDataOnFailure(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())
if v := c.View(); !v.Loaded || len(v.Repos) != 1 || !v.Repos[0].Up || len(v.Repos[0].Issues) != 1 {
t.Fatalf("first view: %+v", v)
}
fail = true
c.Refresh(context.Background())
v := c.View()
r := v.Repos[0]
if r.Up || r.Err != "boom" || len(r.Issues) != 1 {
t.Fatalf("must keep old data and flag error: %+v", r)
}
if v.Crit != 1 || r.Health != model.Crit {
t.Fatalf("crit=%d health=%v", v.Crit, r.Health)
}
if s := c.Stats(); s.RepoErrorsTotal != 1 || s.RefreshTotal != 2 {
t.Fatalf("stats %+v", s)
}
}
func TestFirstRefreshFailureStillListsRepo(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a")
c.Refresh(context.Background())
v := c.View()
if len(v.Repos) != 1 || v.Repos[0].Up || v.Repos[0].URL != "https://github.com/o/a" {
t.Fatalf("%+v", v.Repos)
}
}
func TestRateLimitStopsRemainingRepos(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
if name == "o/b" {
return model.Repo{}, model.ErrRateLimited
}
return model.Repo{FullName: name}, nil
}}
c := newCollector(f, "o/a", "o/b", "o/c", "o/d")
c.Refresh(context.Background())
if len(f.calls) != 2 {
t.Fatalf("calls after limit = %v, want only a and b", f.calls)
}
v := c.View()
if len(v.Repos) != 4 {
t.Fatalf("all repos must stay listed: %d", len(v.Repos))
}
// Next cycle starts with a clean limit flag.
f.fn = func(name string) (model.Repo, error) { return model.Repo{FullName: name}, nil }
f.calls = nil
c.Refresh(context.Background())
if len(f.calls) != 4 {
t.Fatalf("limit flag must reset, calls=%v", f.calls)
}
}
func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) {
f := &fakeFetcher{fn: func(name string) (model.Repo, error) {
r := model.Repo{FullName: name}
switch name {
case "o/crit":
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)}}
}
return r, nil
}}
c := newCollector(f, "o/ok", "o/warn", "o/crit")
c.Refresh(context.Background())
v := c.View()
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 {
t.Fatalf("alerts: %+v", v.Alerts)
}
}
func TestRequestRefreshCoalesces(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
if !c.RequestRefresh() {
t.Fatal("first request must queue")
}
if c.RequestRefresh() {
t.Fatal("second request must coalesce while one is queued")
}
}
func TestRunRefreshesOnStartAndStopsOnCancel(t *testing.T) {
c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
deadline := time.After(2 * time.Second)
for !c.Stats().Loaded {
select {
case <-deadline:
t.Fatal("no initial refresh")
case <-time.After(5 * time.Millisecond):
}
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not stop")
}
}
func TestSummarizeChecks(t *testing.T) {
tests := map[string]struct {
want string
in model.CheckSummary
}{
"few distinct": {in: model.CheckSummary{Failed: 2, Failing: []string{"build", "lint"}}, want: "2 checks failing: build, lint"},
"duplicates collapse and extras count": {
in: model.CheckSummary{Failed: 6, Failing: []string{"pr-checks / Tests", "pr-checks / Tests", "pr-checks / Lint", "pr-checks / Scan", "pr-checks / Gosec"}},
want: "6 checks failing: Gosec, Lint +2 more",
},
"mixed prefixes stay intact": {in: model.CheckSummary{Failed: 2, Failing: []string{"a / x", "b / y"}}, want: "2 checks failing: a / x, b / y"},
"no names": {in: model.CheckSummary{Failed: 1}, want: "1 checks failing"},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := summarizeChecks(tt.in); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestSummarizeWarningsMergesSharedReason(t *testing.T) {
got := summarizeWarnings([]string{
"issues: github: HTTP 403: Resource not accessible by integration",
"workflows: github: HTTP 403: Resource not accessible by integration",
"releases: github: HTTP 500: boom",
})
want := []string{
"issues, workflows: github: HTTP 403: Resource not accessible by integration",
"releases: github: HTTP 500: boom",
}
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("got %q", got)
}
}
+193
View File
@@ -0,0 +1,193 @@
package collector
import (
"fmt"
"slices"
"strings"
"time"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
// Alert kinds; they double as stable identifiers for dashboards.
const (
KindRefreshFailed = "refresh_failed"
KindDataStale = "data_stale"
KindPartialData = "partial_data"
KindWorkflowFail = "workflow_failed"
KindWorkflowStuck = "workflow_stuck"
KindPRWaiting = "pr_waiting"
KindPRChecks = "pr_checks_failing"
KindIssuesStale = "issues_stale"
)
// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health.
// It runs on every read so age-based alerts advance between refreshes.
func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert {
var alerts []model.Alert
add := func(sev model.Severity, kind, subject, detail, url string, since time.Time) {
alerts = append(alerts, model.Alert{Severity: sev, Repo: r.FullName, Kind: kind, Subject: subject, Detail: detail, URL: url, Since: since})
r.Health = max(r.Health, sev)
}
r.Health = model.OK
if r.Err != "" {
add(model.Crit, KindRefreshFailed, "Refresh failed", r.Err, r.URL, r.RefreshedAt)
}
if th.DataStaleAfter > 0 && !r.RefreshedAt.IsZero() && now.Sub(r.RefreshedAt) > th.DataStaleAfter {
add(model.Warn, KindDataStale, "Data is stale", "last refreshed "+Age(now.Sub(r.RefreshedAt))+" ago", r.URL, r.RefreshedAt)
}
for _, detail := range summarizeWarnings(r.Warnings) {
add(model.Warn, KindPartialData, "Incomplete data", detail, r.URL, r.RefreshedAt)
}
if r.Archived {
return alerts
}
for _, wf := range r.Workflows {
if wf.LastDone != nil && wf.LastDone.Failed() && !Dormant(wf, now, th) {
add(model.Crit, KindWorkflowFail, workflowSubject(wf), fmt.Sprintf("%s on %s (%s)", strings.ReplaceAll(wf.LastDone.Conclusion, "_", " "), wf.LastDone.Branch, wf.LastDone.Event), wf.LastDone.URL, wf.LastDone.CreatedAt)
}
if wf.Latest.Running() && th.WorkflowStuck > 0 && now.Sub(wf.Latest.CreatedAt) > th.WorkflowStuck {
add(model.Warn, KindWorkflowStuck, workflowSubject(wf),
fmt.Sprintf("%s for %s", strings.ReplaceAll(wf.Latest.Status, "_", " "), Age(now.Sub(wf.Latest.CreatedAt))),
wf.Latest.URL, wf.Latest.CreatedAt)
}
}
for _, pr := range r.PRs {
if pr.Draft {
continue
}
age := now.Sub(pr.CreatedAt)
subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title)
switch {
case th.PRCritAfter > 0 && age >= th.PRCritAfter:
add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
case th.PRWarnAfter > 0 && age >= th.PRWarnAfter:
add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt)
}
if pr.Checks.State == model.ChecksFail {
add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt)
}
}
if th.IssueStaleAfter > 0 {
var stale int
var oldest time.Time
for _, is := range r.Issues {
if now.Sub(is.UpdatedAt) >= th.IssueStaleAfter {
stale++
if oldest.IsZero() || is.UpdatedAt.Before(oldest) {
oldest = is.UpdatedAt
}
}
}
if stale > 0 {
add(model.Warn, KindIssuesStale, fmt.Sprintf("%d stale issues", stale),
"no activity for "+Age(th.IssueStaleAfter)+" or more",
r.URL+"/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc", oldest)
}
}
return alerts
}
func workflowSubject(wf model.Workflow) string {
if wf.Background {
return wf.Name + " (background)"
}
return wf.Name
}
func sortAlerts(a []model.Alert) {
slices.SortStableFunc(a, func(x, y model.Alert) int {
if x.Severity != y.Severity {
return int(y.Severity) - int(x.Severity)
}
if c := x.Since.Compare(y.Since); c != 0 {
return c
}
return strings.Compare(x.Repo+x.Subject, y.Repo+y.Subject)
})
}
// Age renders a duration at a glance, e.g. "3d", "5h", "12m".
func Age(d time.Duration) string {
switch {
case d < 0:
return "0m"
case d >= 48*time.Hour:
return fmt.Sprintf("%dd", int(d.Hours()/24))
case d >= time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dm", int(d.Minutes()))
}
}
// Dormant reports whether the workflow has not run for longer than the dormant threshold,
// so an old failure no longer signals a current problem.
func Dormant(wf model.Workflow, now time.Time, th config.Thresholds) bool {
return th.WorkflowDormant > 0 && !wf.Latest.CreatedAt.IsZero() && now.Sub(wf.Latest.CreatedAt) > th.WorkflowDormant
}
const maxNamedChecks = 2
// summarizeChecks renders failing checks as a count plus the first few distinct names.
func summarizeChecks(c model.CheckSummary) string {
names := slices.Compact(slices.Sorted(slices.Values(c.Failing)))
if prefix, ok := sharedPrefix(names); ok {
for i, n := range names {
names[i] = strings.TrimPrefix(n, prefix)
}
}
shown := names[:min(len(names), maxNamedChecks)]
out := fmt.Sprintf("%d checks failing", c.Failed)
if len(shown) > 0 {
out += ": " + strings.Join(shown, ", ")
}
if extra := len(names) - len(shown); extra > 0 {
out += fmt.Sprintf(" +%d more", extra)
}
return out
}
// sharedPrefix returns the common "group / " prefix of check names such as "pr-checks / Tests".
func sharedPrefix(names []string) (string, bool) {
if len(names) < 2 {
return "", false
}
group, _, ok := strings.Cut(names[0], " / ")
if !ok {
return "", false
}
prefix := group + " / "
for _, n := range names {
if !strings.HasPrefix(n, prefix) {
return "", false
}
}
return prefix, true
}
// summarizeWarnings merges "section: reason" warnings that share a reason into one line.
func summarizeWarnings(warnings []string) []string {
var reasons []string
sections := map[string][]string{}
for _, w := range warnings {
section, reason, ok := strings.Cut(w, ": ")
if !ok {
section, reason = "data", w
}
if _, seen := sections[reason]; !seen {
reasons = append(reasons, reason)
}
sections[reason] = append(sections[reason], section)
}
out := make([]string, 0, len(reasons))
for _, reason := range reasons {
out = append(out, strings.Join(sections[reason], ", ")+": "+reason)
}
return out
}
+163
View File
@@ -0,0 +1,163 @@
// Package config loads and validates the YAML configuration.
package config
import (
"errors"
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
EnvPrivateKey = "GHTRACKER_PRIVATE_KEY"
DefaultListen = ":8080"
DefaultRefresh = 6 * time.Hour
DefaultAPIURL = "https://api.github.com"
DefaultPRWarnAfter = 48 * time.Hour
DefaultPRCritAfter = 7 * 24 * time.Hour
DefaultIssueStale = 30 * 24 * time.Hour
DefaultWorkflowStuck = 6 * time.Hour
DefaultWorkflowDormant = 30 * 24 * time.Hour
DefaultMaxItems = 100
DefaultConcurrency = 2
MinRefresh = time.Minute
dataStaleGrace = 30 * time.Minute
defaultDataStaleFactor = 2
)
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"`
GitHub GitHub `yaml:"github"`
Thresholds Thresholds `yaml:"thresholds"`
RefreshInterval time.Duration `yaml:"refresh_interval"`
MaxItems int `yaml:"max_items"`
Concurrency int `yaml:"concurrency"`
}
type GitHub struct {
PrivateKeyFile string `yaml:"private_key_file"`
APIURL string `yaml:"api_url"`
// PrivateKeyPEM comes from the environment, never from the file.
PrivateKeyPEM string `yaml:"-"`
AppID int64 `yaml:"app_id"`
InstallationID int64 `yaml:"installation_id"`
}
// Thresholds are durations; zero selects the default and a negative value disables the check.
type Thresholds struct {
PRWarnAfter time.Duration `yaml:"pr_warn_after"`
PRCritAfter time.Duration `yaml:"pr_crit_after"`
IssueStaleAfter time.Duration `yaml:"issue_stale_after"`
WorkflowStuck time.Duration `yaml:"workflow_stuck_after"`
// WorkflowDormant mutes failures of workflows that have not run for this long.
WorkflowDormant time.Duration `yaml:"workflow_dormant_after"`
DataStaleAfter time.Duration `yaml:"data_stale_after"`
}
func Load(path string) (*Config, error) {
raw, err := os.ReadFile(path) // #nosec G304 -- operator-supplied -config flag
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var c Config
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true)
if err := dec.Decode(&c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &c, c.finalize(os.Getenv, os.ReadFile)
}
func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]byte, error)) error {
if c.ManualRefresh == nil {
on := true
c.ManualRefresh = &on
}
if c.Listen == "" {
c.Listen = DefaultListen
}
if c.RefreshInterval == 0 {
c.RefreshInterval = DefaultRefresh
}
if c.RefreshInterval < MinRefresh {
return fmt.Errorf("refresh_interval %s is below the %s minimum", c.RefreshInterval, MinRefresh)
}
if c.MaxItems <= 0 {
c.MaxItems = DefaultMaxItems
}
if c.Concurrency <= 0 {
c.Concurrency = DefaultConcurrency
}
if c.GitHub.APIURL == "" {
c.GitHub.APIURL = DefaultAPIURL
}
c.GitHub.APIURL = strings.TrimRight(c.GitHub.APIURL, "/")
t := &c.Thresholds
setDefault(&t.PRWarnAfter, DefaultPRWarnAfter)
setDefault(&t.PRCritAfter, DefaultPRCritAfter)
setDefault(&t.IssueStaleAfter, DefaultIssueStale)
setDefault(&t.WorkflowStuck, DefaultWorkflowStuck)
setDefault(&t.WorkflowDormant, DefaultWorkflowDormant)
setDefault(&t.DataStaleAfter, defaultDataStaleFactor*c.RefreshInterval+dataStaleGrace)
if t.PRWarnAfter > 0 && t.PRCritAfter > 0 && t.PRCritAfter < t.PRWarnAfter {
return errors.New("thresholds.pr_crit_after must not be below pr_warn_after")
}
if c.GitHub.AppID <= 0 {
return errors.New("github.app_id is required")
}
c.GitHub.PrivateKeyPEM = getenv(EnvPrivateKey)
if c.GitHub.PrivateKeyPEM == "" {
if c.GitHub.PrivateKeyFile == "" {
return fmt.Errorf("github.private_key_file or $%s is required", EnvPrivateKey)
}
pem, err := readFile(c.GitHub.PrivateKeyFile)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
c.GitHub.PrivateKeyPEM = string(pem)
}
repos, err := normalizeRepos(c.Repos)
if err != nil {
return err
}
c.Repos = repos
return nil
}
func setDefault(d *time.Duration, def time.Duration) {
if *d == 0 {
*d = def
}
}
func normalizeRepos(in []string) ([]string, error) {
if len(in) == 0 {
return nil, errors.New("repos must list at least one owner/name")
}
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, r := range in {
r = strings.TrimSpace(r)
owner, name, ok := strings.Cut(r, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return nil, fmt.Errorf("invalid repo %q: want owner/name", r)
}
key := strings.ToLower(r)
if seen[key] {
continue
}
seen[key] = true
out = append(out, r)
}
return out, nil
}
+128
View File
@@ -0,0 +1,128 @@
package config
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func noFile(string) ([]byte, error) { return nil, errors.New("no file") }
func keyFile(string) ([]byte, error) { return []byte("PEM"), nil }
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
func valid() *Config {
return &Config{GitHub: GitHub{AppID: 1, PrivateKeyFile: "k.pem"}, Repos: []string{"a/b"}}
}
func TestFinalizeDefaults(t *testing.T) {
c := valid()
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if c.RefreshInterval != 6*time.Hour || c.Listen != ":8080" || c.Concurrency != 2 || c.MaxItems != 100 {
t.Fatalf("defaults wrong: %+v", c)
}
if c.ManualRefresh == nil || !*c.ManualRefresh {
t.Fatal("manual refresh must default to on")
}
if c.GitHub.APIURL != DefaultAPIURL || c.GitHub.PrivateKeyPEM != "PEM" {
t.Fatalf("github defaults wrong: %+v", c.GitHub)
}
if want := 12*time.Hour + 30*time.Minute; c.Thresholds.DataStaleAfter != want {
t.Fatalf("data stale = %s, want %s", c.Thresholds.DataStaleAfter, want)
}
}
func TestFinalizeEnvKeyWins(t *testing.T) {
c := valid()
c.GitHub.PrivateKeyFile = ""
if err := c.finalize(env(map[string]string{EnvPrivateKey: "ENVPEM"}), noFile); err != nil {
t.Fatal(err)
}
if c.GitHub.PrivateKeyPEM != "ENVPEM" {
t.Fatalf("got %q", c.GitHub.PrivateKeyPEM)
}
}
func TestFinalizeErrors(t *testing.T) {
tests := map[string]func(*Config){
"no app id": func(c *Config) { c.GitHub.AppID = 0 },
"no key": func(c *Config) { c.GitHub.PrivateKeyFile = "" },
"unreadable key": func(c *Config) { c.GitHub.PrivateKeyFile = "x" },
"no repos": func(c *Config) { c.Repos = nil },
"bad repo": func(c *Config) { c.Repos = []string{"justname"} },
"bad repo slashes": func(c *Config) { c.Repos = []string{"a/b/c"} },
"refresh too small": func(c *Config) { c.RefreshInterval = time.Second },
"crit below warn": func(c *Config) {
c.Thresholds.PRWarnAfter, c.Thresholds.PRCritAfter = 10*time.Hour, time.Hour
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
c := valid()
mutate(c)
if err := c.finalize(env(nil), noFile); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestNormalizeReposDedupes(t *testing.T) {
got, err := normalizeRepos([]string{"A/b", " a/B ", "c/d"})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != "A/b" || got[1] != "c/d" {
t.Fatalf("got %v", got)
}
}
func TestNegativeThresholdDisables(t *testing.T) {
c := valid()
c.Thresholds.PRCritAfter = -1
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if c.Thresholds.PRCritAfter != -1 {
t.Fatalf("negative must be kept, got %s", c.Thresholds.PRCritAfter)
}
}
func TestLoadRejectsUnknownKeysAndReadsFile(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
key := write("k.pem", "PEM")
good := write("ok.yaml", "refresh_interval: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
c, err := Load(good)
if err != nil || c.RefreshInterval != 2*time.Hour || c.GitHub.PrivateKeyPEM != "PEM" {
t.Fatalf("c=%+v err=%v", c, err)
}
typo := write("typo.yaml", "refresh_intervall: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n")
if _, err := Load(typo); err == nil {
t.Fatal("unknown key must be rejected")
}
if _, err := Load(filepath.Join(dir, "missing.yaml")); err == nil {
t.Fatal("missing file must error")
}
}
func TestManualRefreshCanBeDisabled(t *testing.T) {
off := false
c := valid()
c.ManualRefresh = &off
if err := c.finalize(env(nil), keyFile); err != nil {
t.Fatal(err)
}
if *c.ManualRefresh {
t.Fatal("explicit false must stay off")
}
}
+181
View File
@@ -0,0 +1,181 @@
package gh
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
const (
jwtLifetime = 9 * time.Minute // GitHub caps app JWTs at 10 minutes
jwtClockSkew = 60 * time.Second
tokenSafety = 5 * time.Minute
maxAuthBody = 1 << 20
installLookup = "/repos/%s/installation"
)
// AppAuth mints GitHub App installation tokens and caches them until shortly before expiry.
type AppAuth struct {
key *rsa.PrivateKey
hc *http.Client
now func() time.Time
tokens map[int64]cachedToken
repoInstalls map[string]int64
base string
appID int64
fixedInstall int64
mu sync.Mutex
}
type cachedToken struct {
expires time.Time
value string
}
func NewAppAuth(appID int64, keyPEM, baseURL string, installationID int64, hc *http.Client) (*AppAuth, error) {
key, err := ParsePrivateKey([]byte(keyPEM))
if err != nil {
return nil, err
}
return &AppAuth{
appID: appID,
key: key,
base: strings.TrimRight(baseURL, "/"),
hc: hc,
fixedInstall: installationID,
now: time.Now,
tokens: map[int64]cachedToken{},
repoInstalls: map[string]int64{},
}, nil
}
// ParsePrivateKey accepts PKCS#1 and PKCS#8 PEM RSA keys.
func ParsePrivateKey(data []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("private key: no PEM block found")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("private key: unsupported format: %w", err)
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key: not an RSA key")
}
return rk, nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func (a *AppAuth) appJWT() (string, error) {
now := a.now()
header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
claims, _ := json.Marshal(map[string]any{
"iat": now.Add(-jwtClockSkew).Unix(),
"exp": now.Add(jwtLifetime).Unix(),
"iss": fmt.Sprint(a.appID),
})
signing := b64(header) + "." + b64(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:])
if err != nil {
return "", fmt.Errorf("sign app jwt: %w", err)
}
return signing + "." + b64(sig), nil
}
// Token returns an installation token valid for repo ("owner/name").
func (a *AppAuth) Token(ctx context.Context, repo string) (string, error) {
id, err := a.installationID(ctx, repo)
if err != nil {
return "", err
}
a.mu.Lock()
t, ok := a.tokens[id]
a.mu.Unlock()
if ok && a.now().Before(t.expires.Add(-tokenSafety)) {
return t.value, nil
}
return a.refresh(ctx, id)
}
func (a *AppAuth) installationID(ctx context.Context, repo string) (int64, error) {
if a.fixedInstall != 0 {
return a.fixedInstall, nil
}
a.mu.Lock()
id, ok := a.repoInstalls[repo]
a.mu.Unlock()
if ok {
return id, nil
}
var out struct {
ID int64 `json:"id"`
}
if err := a.appRequest(ctx, http.MethodGet, fmt.Sprintf(installLookup, repo), &out); err != nil {
return 0, fmt.Errorf("find installation for %s: %w", repo, err)
}
a.mu.Lock()
a.repoInstalls[repo] = out.ID
a.mu.Unlock()
return out.ID, nil
}
func (a *AppAuth) refresh(ctx context.Context, id int64) (string, error) {
var out struct {
ExpiresAt time.Time `json:"expires_at"`
Token string `json:"token"`
}
if err := a.appRequest(ctx, http.MethodPost, fmt.Sprintf("/app/installations/%d/access_tokens", id), &out); err != nil {
return "", fmt.Errorf("create installation token: %w", err)
}
a.mu.Lock()
a.tokens[id] = cachedToken{value: out.Token, expires: out.ExpiresAt}
a.mu.Unlock()
return out.Token, nil
}
func (a *AppAuth) appRequest(ctx context.Context, method, path string, out any) error {
jwt, err := a.appJWT()
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, method, a.base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", acceptHeader)
req.Header.Set("X-GitHub-Api-Version", apiVersion)
req.Header.Set("User-Agent", userAgent)
resp, err := a.hc.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthBody))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return &APIError{Status: resp.StatusCode, Message: apiMessage(body)}
}
return json.Unmarshal(body, out)
}
+291
View File
@@ -0,0 +1,291 @@
// Package gh is a small read-only GitHub REST client authenticated as a GitHub App.
package gh
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const (
acceptHeader = "application/vnd.github+json"
apiVersion = "2022-11-28"
userAgent = "ghtracker"
perPage = 100
maxBody = 16 << 20
maxRetries = 2
// rateReserve is the request budget left untouched so the app never exhausts its hourly quota.
rateReserve = 100
// secondaryBackoff applies when GitHub signals abuse limits without a Retry-After.
secondaryBackoff = time.Minute
)
// APIError is a non-2xx GitHub response.
type APIError struct {
Message string
Status int
}
func (e *APIError) Error() string {
return fmt.Sprintf("github: HTTP %d: %s", e.Status, e.Message)
}
func apiMessage(body []byte) string {
var m struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &m) == nil && m.Message != "" {
return m.Message
}
s := strings.TrimSpace(string(body))
if len(s) > 200 {
s = s[:200]
}
return s
}
func isNotFound(err error) bool {
var ae *APIError
return errors.As(err, &ae) && ae.Status == http.StatusNotFound
}
// TokenSource yields a bearer token for requests against repo.
type TokenSource interface {
Token(ctx context.Context, repo string) (string, error)
}
type Client struct {
rateReset time.Time
blockedUntil time.Time
auth TokenSource
hc *http.Client
// sleep is replaced in tests.
sleep func(context.Context, time.Duration) error
now func() time.Time
cache map[string]cachedResponse
base string
rateRemaining atomic.Int64
maxItems int
mu sync.Mutex
}
// cachedResponse backs conditional requests: a 304 reply does not count against the primary rate limit.
type cachedResponse struct {
etag string
link string
body []byte
}
func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client {
c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}}
c.rateRemaining.Store(-1)
return c
}
// RateLimitRemaining returns the last reported remaining request budget.
func (c *Client) RateLimitRemaining() (int, bool) {
v := c.rateRemaining.Load()
return int(v), v >= 0
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// do performs one authenticated GET, retrying transient failures, and returns the body and Link header.
func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
if err := c.sleep(ctx, time.Duration(attempt)*time.Second); err != nil {
return nil, "", err
}
}
body, link, retry, err := c.once(ctx, repo, target)
if err == nil {
return body, link, nil
}
lastErr = err
if !retry {
break
}
}
return nil, "", lastErr
}
func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) {
if gerr := c.guard(); gerr != nil {
return nil, "", false, gerr
}
tok, err := c.auth.Token(ctx, repo)
if err != nil {
return nil, "", false, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, "", false, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", acceptHeader)
req.Header.Set("X-GitHub-Api-Version", apiVersion)
req.Header.Set("User-Agent", userAgent)
c.mu.Lock()
cached, haveCache := c.cache[target]
c.mu.Unlock()
if haveCache {
req.Header.Set("If-None-Match", cached.etag)
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, "", ctx.Err() == nil, err
}
defer func() { _ = resp.Body.Close() }()
c.noteRate(resp.Header)
if resp.StatusCode == http.StatusNotModified && haveCache {
return cached.body, cached.link, false, nil
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, "", true, err
}
if resp.StatusCode/100 != 2 {
ae := &APIError{Status: resp.StatusCode, Message: apiMessage(data)}
if c.noteLimited(resp, ae) {
return nil, "", false, fmt.Errorf("%w: %v", model.ErrRateLimited, ae)
}
return nil, "", resp.StatusCode >= 500, ae
}
link = resp.Header.Get("Link")
if etag := resp.Header.Get("ETag"); etag != "" {
c.mu.Lock()
c.cache[target] = cachedResponse{etag: etag, body: data, link: link}
c.mu.Unlock()
}
return data, link, false, nil
}
// guard refuses requests while a limit block is active or the remaining budget is at the reserve.
func (c *Client) guard() error {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
if now.Before(c.blockedUntil) {
return fmt.Errorf("%w: blocked until %s", model.ErrRateLimited, c.blockedUntil.Format(time.RFC3339))
}
if rem := c.rateRemaining.Load(); rem >= 0 && rem < rateReserve && now.Before(c.rateReset) {
return fmt.Errorf("%w: %d requests left, resets %s", model.ErrRateLimited, rem, c.rateReset.Format(time.RFC3339))
}
return nil
}
func (c *Client) noteRate(h http.Header) {
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Remaining"), 10, 64); err == nil {
c.rateRemaining.Store(v)
}
if v, err := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64); err == nil {
c.mu.Lock()
c.rateReset = time.Unix(v, 0)
c.mu.Unlock()
}
}
// noteLimited records a block when the response is a primary or secondary rate-limit rejection.
func (c *Client) noteLimited(resp *http.Response, ae *APIError) bool {
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests {
return false
}
now := c.now()
var until time.Time
switch secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); {
case err == nil:
until = now.Add(time.Duration(secs) * time.Second)
case resp.Header.Get("X-RateLimit-Remaining") == "0":
c.mu.Lock()
until = c.rateReset
c.mu.Unlock()
case strings.Contains(strings.ToLower(ae.Message), "rate limit"):
until = now.Add(secondaryBackoff)
default:
return false
}
c.mu.Lock()
c.blockedUntil = until
c.mu.Unlock()
return true
}
func (c *Client) url(path string, q url.Values) string {
u := c.base + path
if len(q) > 0 {
u += "?" + q.Encode()
}
return u
}
func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error {
body, _, err := c.do(ctx, repo, c.url(path, q))
if err != nil {
return err
}
return json.Unmarshal(body, out)
}
// listAll follows Link pagination, collecting at most limit items.
func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Values, limit int, unwrap func([]byte) ([]T, error)) ([]T, error) {
if q == nil {
q = url.Values{}
}
q.Set("per_page", strconv.Itoa(perPage))
next := c.url(path, q)
var all []T
for next != "" && len(all) < limit {
body, link, err := c.do(ctx, repo, next)
if err != nil {
return all, err
}
page, err := unwrap(body)
if err != nil {
return all, err
}
all = append(all, page...)
next = nextLink(link)
}
if len(all) > limit {
all = all[:limit]
}
return all, nil
}
func unwrapArray[T any](b []byte) ([]T, error) {
var out []T
return out, json.Unmarshal(b, &out)
}
// nextLink extracts rel="next" from a Link header.
func nextLink(h string) string {
for part := range strings.SplitSeq(h, ",") {
target, params, found := strings.Cut(part, ";")
if found && strings.TrimSpace(params) == `rel="next"` {
return strings.Trim(strings.TrimSpace(target), "<>")
}
}
return ""
}
+345
View File
@@ -0,0 +1,345 @@
package gh
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"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"
)
// backgroundEvents are triggers that run without a person pushing code.
var backgroundEvents = []string{"schedule", "dynamic", "workflow_dispatch", "workflow_run", "repository_dispatch"}
type userRef struct {
Login string `json:"login"`
}
type labelRef struct {
Name string `json:"name"`
}
func labelNames(ls []labelRef) []string {
out := make([]string, 0, len(ls))
for _, l := range ls {
out = append(out, l.Name)
}
return out
}
// FetchRepo gathers the dashboard data for one "owner/name" repository.
// A failure of the repository lookup fails the call; other sections degrade into Repo.Warnings.
func (c *Client) FetchRepo(ctx context.Context, full string) (model.Repo, error) {
var info struct {
HTMLURL string `json:"html_url"`
Description string `json:"description"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
}
if err := c.getJSON(ctx, full, "/repos/"+full, nil, &info); err != nil {
return model.Repo{}, fmt.Errorf("repo %s: %w", full, err)
}
r := model.Repo{
FullName: full,
URL: info.HTMLURL,
ReleasesURL: info.HTMLURL + "/releases",
Description: info.Description,
DefaultBranch: info.DefaultBranch,
Archived: info.Archived,
Up: true,
}
var limited error
warn := func(section string, err error) {
if errors.Is(err, model.ErrRateLimited) {
limited = err
}
r.Warnings = append(r.Warnings, fmt.Sprintf("%s: %v", section, err))
}
var err error
if r.Issues, err = c.openIssues(ctx, full); err != nil {
warn("issues", err)
}
if r.PRs, err = c.openPRs(ctx, full); err != nil {
warn("pull requests", err)
}
if cerr := c.attachChecks(ctx, full, r.PRs); cerr != nil {
warn("pr checks", cerr)
}
if r.Workflows, err = c.workflows(ctx, full, r.DefaultBranch); err != nil {
warn("workflows", err)
}
if r.Release, err = c.latestRelease(ctx, full, r.URL); err != nil {
warn("releases", err)
}
if limited != nil {
return model.Repo{}, limited
}
return r, nil
}
func (c *Client) openIssues(ctx context.Context, full string) ([]model.Issue, error) {
type raw struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
User userRef `json:"user"`
Labels []labelRef `json:"labels"`
PullRequest json.RawMessage `json:"pull_request"`
Number int `json:"number"`
Comments int `json:"comments"`
}
// The issues endpoint also returns pull requests; they are dropped here and fetched separately.
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
items, err := listAll(ctx, c, full, "/repos/"+full+"/issues", q, c.maxItems, unwrapArray[raw])
out := make([]model.Issue, 0, len(items))
for _, i := range items {
if len(i.PullRequest) > 0 {
continue
}
out = append(out, model.Issue{
Number: i.Number, Title: i.Title, URL: i.HTMLURL, Author: i.User.Login,
Labels: labelNames(i.Labels), Comments: i.Comments, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt,
})
}
return out, err
}
func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, error) {
type raw struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
User userRef `json:"user"`
Head struct {
SHA string `json:"sha"`
} `json:"head"`
Labels []labelRef `json:"labels"`
Number int `json:"number"`
Draft bool `json:"draft"`
}
q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}}
items, err := listAll(ctx, c, full, "/repos/"+full+"/pulls", q, c.maxItems, unwrapArray[raw])
out := make([]model.PullRequest, 0, len(items))
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,
Checks: model.CheckSummary{State: model.ChecksUnknown},
})
}
return out, err
}
// 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
for i := range prs {
s, err := c.checkSummary(ctx, full, prs[i].HeadSHA)
if err != nil {
if first == nil {
first = fmt.Errorf("#%d: %w", prs[i].Number, err)
}
continue
}
prs[i].Checks = s
}
return first
}
func (c *Client) checkSummary(ctx context.Context, full, sha string) (model.CheckSummary, error) {
var runs struct {
CheckRuns []struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"check_runs"`
}
base := "/repos/" + full + "/commits/" + sha
if err := c.getJSON(ctx, full, base+"/check-runs", url.Values{"per_page": {"100"}}, &runs); err != nil {
return model.CheckSummary{}, err
}
var status struct {
Statuses []struct {
Context string `json:"context"`
State string `json:"state"`
} `json:"statuses"`
TotalCount int `json:"total_count"`
}
if err := c.getJSON(ctx, full, base+"/status", nil, &status); err != nil {
return model.CheckSummary{}, err
}
var s model.CheckSummary
for _, cr := range runs.CheckRuns {
s.Total++
switch {
case cr.Status != "completed":
s.Pending++
case (model.Run{Status: cr.Status, Conclusion: cr.Conclusion}).Failed():
s.Failed++
s.Failing = append(s.Failing, cr.Name)
}
}
for _, st := range status.Statuses {
s.Total++
switch st.State {
case statusStateFail, statusStateError:
s.Failed++
s.Failing = append(s.Failing, st.Context)
case statusStatePend:
s.Pending++
}
}
if len(s.Failing) > maxFailingNames {
s.Failing = s.Failing[:maxFailingNames]
}
switch {
case s.Total == 0:
s.State = model.ChecksNone
case s.Failed > 0:
s.State = model.ChecksFail
case s.Pending > 0:
s.State = model.ChecksPending
default:
s.State = model.ChecksPass
}
return s, nil
}
type rawRun struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
Event string `json:"event"`
HeadBranch string `json:"head_branch"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
ID int64 `json:"id"`
}
func (r rawRun) model() model.Run {
return model.Run{
ID: r.ID, URL: r.HTMLURL, Event: r.Event, Branch: r.HeadBranch, Status: r.Status,
Conclusion: r.Conclusion, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}
}
// workflows returns, per active workflow, its newest default-branch run and newest completed one.
// Pull request runs are excluded because PR checks cover them.
func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Workflow, error) {
var list struct {
Workflows []struct {
Name string `json:"name"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
ID int64 `json:"id"`
} `json:"workflows"`
}
if err := c.getJSON(ctx, full, "/repos/"+full+"/actions/workflows", url.Values{"per_page": {"100"}}, &list); err != nil {
return nil, err
}
var out []model.Workflow
var first error
for _, w := range list.Workflows {
if w.State != "active" || len(out) >= maxWorkflows {
continue
}
var runs struct {
WorkflowRuns []rawRun `json:"workflow_runs"`
}
q := url.Values{
"branch": {branch},
"exclude_pull_requests": {"true"},
"per_page": {fmt.Sprint(runsPerWorkflow)},
}
path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID)
if err := c.getJSON(ctx, full, path, q, &runs); err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", w.Name, err)
}
continue
}
if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok {
out = append(out, wf)
}
}
return out, first
}
func buildWorkflow(name, htmlURL string, runs []rawRun) (model.Workflow, bool) {
var kept []model.Run
for _, r := range runs {
if r.Event == eventPullRequest || r.Event == eventPullReqTgt {
continue
}
kept = append(kept, r.model())
}
if len(kept) == 0 {
return model.Workflow{}, false
}
slices.SortStableFunc(kept, func(a, b model.Run) int { return b.CreatedAt.Compare(a.CreatedAt) })
wf := model.Workflow{
Name: name,
URL: htmlURL,
Latest: kept[0],
Background: slices.Contains(backgroundEvents, kept[0].Event),
}
for i := range kept {
if !kept[i].Running() {
done := kept[i]
wf.LastDone = &done
break
}
}
return wf, true
}
func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*model.Release, error) {
var rel struct {
PublishedAt time.Time `json:"published_at"`
TagName string `json:"tag_name"`
Name string `json:"name"`
HTMLURL string `json:"html_url"`
Prerelease bool `json:"prerelease"`
}
err := c.getJSON(ctx, full, "/repos/"+full+"/releases/latest", nil, &rel)
if err == nil {
return &model.Release{Tag: rel.TagName, Name: rel.Name, URL: rel.HTMLURL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease}, nil
}
if !isNotFound(err) {
return nil, err
}
// No published release: fall back to the newest tag so unreleased-but-tagged projects show a version.
var tags []struct {
Name string `json:"name"`
}
if err := c.getJSON(ctx, full, "/repos/"+full+"/tags", url.Values{"per_page": {"1"}}, &tags); err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
return &model.Release{
Tag: tags[0].Name,
URL: repoURL + "/releases/tag/" + url.PathEscape(tags[0].Name),
FromTag: true,
}, nil
}
+461
View File
@@ -0,0 +1,461 @@
package gh
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
func testKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
k, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})
return k, string(p)
}
// fakeGitHub serves canned JSON by path and counts hits per path.
type fakeGitHub struct {
srv *httptest.Server
routes map[string]func(w http.ResponseWriter, r *http.Request)
hits map[string]*atomic.Int64
appKeyPub *rsa.PublicKey
t *testing.T
installs atomic.Int64
tokens atomic.Int64
}
func newFake(t *testing.T) *fakeGitHub {
t.Helper()
f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}}
f.srv = httptest.NewServer(http.HandlerFunc(f.serve))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/installation"):
f.installs.Add(1)
f.checkJWT(r)
_, _ = w.Write([]byte(`{"id":42}`))
return
case strings.HasPrefix(r.URL.Path, "/app/installations/"):
f.tokens.Add(1)
f.checkJWT(r)
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
_, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp)
return
}
if r.Header.Get("Authorization") != "Bearer inst-token" {
http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized)
return
}
h, ok := f.routes[r.URL.Path]
if !ok {
http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound)
return
}
c := f.hits[r.URL.Path]
if c == nil {
c = &atomic.Int64{}
f.hits[r.URL.Path] = c
}
c.Add(1)
h(w, r)
}
func (f *fakeGitHub) checkJWT(r *http.Request) {
if f.appKeyPub == nil {
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
parts := strings.Split(tok, ".")
if len(parts) != 3 {
f.t.Errorf("jwt has %d parts", len(parts))
return
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil {
f.t.Errorf("jwt signature invalid: %v", err)
}
}
func (f *fakeGitHub) json(path, body string) {
f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }
}
func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client {
t.Helper()
key, pemStr := testKey(t)
f.appKeyPub = &key.PublicKey
auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client())
if err != nil {
t.Fatal(err)
}
c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems)
c.sleep = func(context.Context, time.Duration) error { return nil }
return c
}
func TestJWTClaimsAndKeyFormats(t *testing.T) {
key, pemStr := testKey(t)
a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient)
if err != nil {
t.Fatal(err)
}
fixed := time.Unix(1_700_000_000, 0)
a.now = func() time.Time { return fixed }
jwt, err := a.appJWT()
if err != nil {
t.Fatal(err)
}
parts := strings.Split(jwt, ".")
claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(claimsRaw, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 {
t.Fatalf("claims = %v", claims)
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
t.Fatal(err)
}
der, _ := x509.MarshalPKCS8PrivateKey(key)
if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil {
t.Fatalf("pkcs8: %v", err)
}
if _, err := ParsePrivateKey([]byte("garbage")); err == nil {
t.Fatal("garbage key must fail")
}
}
func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
for range 3 {
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
}
if f.installs.Load() != 1 || f.tokens.Load() != 1 {
t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load())
}
}
func TestPaginationStopsAtLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 3)
f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("page") == "2" {
_, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`))
return
}
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", <x>; rel="last"`, f.srv.URL))
_, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`))
}
got, err := c.openIssues(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d issues, want 3 (limit)", len(got))
}
}
func TestNextLink(t *testing.T) {
h := `<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"`
if got := nextLink(h); got != "https://api/x?page=2" {
t.Fatal(got)
}
if nextLink(`<https://api/x?page=1>; rel="prev"`) != "" || nextLink("") != "" {
t.Fatal("no next link expected")
}
}
func TestETagConditionalRequestServedFromCache(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var conditional atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"v1"` {
conditional.Add(1)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", `"v1"`)
_, _ = w.Write([]byte(`{"default_branch":"main"}`))
}
for range 2 {
var out struct {
DefaultBranch string `json:"default_branch"`
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" {
t.Fatalf("out=%+v err=%v", out, err)
}
}
if conditional.Load() != 1 {
t.Fatalf("conditional requests = %d, want 1", conditional.Load())
}
}
func TestRetriesServerErrorsThenSucceeds(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
var n atomic.Int64
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
if n.Add(1) < 3 {
http.Error(w, `{"message":"boom"}`, http.StatusBadGateway)
return
}
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if n.Load() != 3 {
t.Fatalf("attempts = %d", n.Load())
}
}
func TestNoRetryOnClientError(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{})
if !isNotFound(err) {
t.Fatalf("err = %v", err)
}
}
func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
reset := time.Now().Add(30 * time.Minute)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix()))
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
hits := f.hits["/repos/o/r"].Load()
err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits {
t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load())
}
}
func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
now := time.Now()
c.now = func() time.Time { return now }
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "120")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
c.now = func() time.Time { return now.Add(119 * time.Second) }
if err := c.guard(); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("still blocked at 119s, got %v", err)
}
c.now = func() time.Time { return now.Add(121 * time.Second) }
if err := c.guard(); err != nil {
t.Fatalf("unblocked at 121s, got %v", err)
}
}
func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-RateLimit-Remaining", "50")
w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix()))
_, _ = w.Write([]byte(`{}`))
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil {
t.Fatal(err)
}
if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("budget below reserve must refuse, got %v", err)
}
if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 {
t.Fatalf("remaining = %d %v", rem, ok)
}
}
func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{})
var ae *APIError
if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 {
t.Fatalf("err = %v", err)
}
if err := c.guard(); err != nil {
t.Fatalf("plain 403 must not block: %v", err)
}
}
func registerRepo(f *fakeGitHub) {
f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`)
f.json("/repos/o/r/issues", `[
{"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"},
{"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`)
f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`)
f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`)
f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`)
f.json("/repos/o/r/actions/workflows", `{"workflows":[
{"id":1,"name":"CI","html_url":"w1","state":"active"},
{"id":2,"name":"Nightly","html_url":"w2","state":"active"},
{"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"},
{"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`)
f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[
{"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"},
{"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[
{"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`)
f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`)
f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`)
}
func TestFetchRepoEndToEnd(t *testing.T) {
f := newFake(t)
c := f.client(t, 50)
registerRepo(f)
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 0 {
t.Fatalf("warnings: %v", r.Warnings)
}
if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" {
t.Fatalf("issues must exclude PRs: %+v", r.Issues)
}
if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 {
t.Fatalf("pr checks: %+v", r.PRs)
}
if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" {
t.Fatalf("failing names: %v", got)
}
if len(r.Workflows) != 2 {
t.Fatalf("want CI and Nightly only, got %+v", r.Workflows)
}
ci, nightly := r.Workflows[0], r.Workflows[1]
if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background {
t.Fatalf("ci: %+v", ci)
}
if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() {
t.Fatalf("nightly: %+v", nightly)
}
if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" {
t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL)
}
}
func TestReleaseFallsBackToTag(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil {
t.Fatal(err)
}
if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" {
t.Fatalf("%+v", rel)
}
}
func TestReleaseNoneAtAll(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
f.json("/repos/o/r/tags", `[]`)
rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r")
if err != nil || rel != nil {
t.Fatalf("rel=%+v err=%v", rel, err)
}
}
func TestSectionFailureDegradesToWarning(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden)
}
r, err := c.FetchRepo(context.Background(), "o/r")
if err != nil {
t.Fatal(err)
}
if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 {
t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues))
}
}
func TestRateLimitInSectionFailsWholeRepo(t *testing.T) {
f := newFake(t)
c := f.client(t, 10)
registerRepo(f)
f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden)
}
if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) {
t.Fatalf("err = %v", err)
}
}
func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) {
runs := []rawRun{
{ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)},
{ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)},
}
wf, ok := buildWorkflow("CI", "u", runs)
if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 {
t.Fatalf("%+v", wf)
}
if _, ok := buildWorkflow("CI", "u", runs[:1]); ok {
t.Fatal("only PR runs must yield no workflow")
}
}
func TestAPIErrorMessageParsing(t *testing.T) {
if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" {
t.Fatal(got)
}
if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 {
t.Fatalf("len=%d", len(got))
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package metrics exposes dashboard state as Prometheus metrics, computed at scrape time
// so age-based series keep advancing between refreshes.
package metrics
import (
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
const ns = "ghtracker"
// Source supplies the state to export.
type Source interface {
View() collector.View
Stats() collector.Stats
}
// RateLimit reports the remaining GitHub request budget; ok is false before the first response.
type RateLimit func() (remaining int, ok bool)
type Collector struct {
src Source
rate RateLimit
d map[string]*prometheus.Desc
}
var (
repoLabels = []string{"repo"}
prLabels = []string{"repo", "number"}
workflowLabels = []string{"repo", "workflow", "background"}
)
var descs = []struct {
key, help string
labels []string
}{
{"repo_up", "1 if the last refresh of the repo succeeded.", repoLabels},
{"repo_health", "Worst alert severity for the repo: 0 ok, 1 warn, 2 crit.", repoLabels},
{"repo_last_refresh_timestamp_seconds", "Unix time of the last successful refresh of the repo.", repoLabels},
{"repo_open_issues", "Open issues, excluding pull requests.", repoLabels},
{"repo_open_prs", "Open pull requests, including drafts.", repoLabels},
{"repo_open_prs_failing_checks", "Open non-draft pull requests whose checks fail.", repoLabels},
{"repo_oldest_pr_age_seconds", "Age of the oldest open non-draft pull request; 0 if none.", repoLabels},
{"repo_stale_issues", "Open issues without activity for longer than thresholds.issue_stale_after.", repoLabels},
{"pr_age_seconds", "Age of an open non-draft pull request.", prLabels},
{"pr_checks_failing", "1 if the pull request has failing checks.", prLabels},
{"workflow_failing", "1 if the newest completed default-branch run of the workflow failed.", workflowLabels},
{"workflow_running", "1 if the workflow has a run in progress or queued.", workflowLabels},
{"workflow_last_run_timestamp_seconds", "Unix time the newest default-branch run was created.", workflowLabels},
{"release_info", "Latest release or tag; value is always 1.", []string{"repo", "version", "url"}},
{"release_published_timestamp_seconds", "Unix time the latest release was published.", repoLabels},
{"alerts", "Active alerts by severity.", []string{"severity"}},
{"last_refresh_timestamp_seconds", "Unix time the last refresh started.", nil},
{"last_refresh_duration_seconds", "Duration of the last refresh.", nil},
{"refreshes_total", "Completed refresh cycles.", nil},
{"repo_refresh_errors_total", "Repo refresh failures.", nil},
{"github_rate_limit_remaining", "Remaining GitHub API requests in the current window.", nil},
}
func New(src Source, rate RateLimit) *Collector {
c := &Collector{src: src, rate: rate, d: make(map[string]*prometheus.Desc, len(descs))}
for _, d := range descs {
c.d[d.key] = prometheus.NewDesc(prometheus.BuildFQName(ns, "", d.key), d.help, d.labels, nil)
}
return c
}
func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
for _, d := range c.d {
ch <- d
}
}
func (c *Collector) Collect(ch chan<- prometheus.Metric) {
g := func(key string, v float64, labels ...string) {
ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.GaugeValue, v, labels...)
}
ctr := func(key string, v float64) {
ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.CounterValue, v)
}
v, st := c.src.View(), c.src.Stats()
for _, r := range v.Repos {
c.collectRepo(g, r, v)
}
for _, sev := range []model.Severity{model.Crit, model.Warn} {
n := v.Crit
if sev == model.Warn {
n = v.Warn
}
g("alerts", float64(n), sev.String())
}
g("last_refresh_timestamp_seconds", unix(st.LastRefresh))
g("last_refresh_duration_seconds", st.LastDuration.Seconds())
ctr("refreshes_total", float64(st.RefreshTotal))
ctr("repo_refresh_errors_total", float64(st.RepoErrorsTotal))
if c.rate != nil {
if rem, ok := c.rate(); ok {
g("github_rate_limit_remaining", float64(rem))
}
}
}
func (c *Collector) collectRepo(g func(string, float64, ...string), r model.Repo, v collector.View) {
g("repo_up", b2f(r.Up), r.FullName)
g("repo_health", float64(r.Health), r.FullName)
g("repo_last_refresh_timestamp_seconds", unix(r.RefreshedAt), r.FullName)
g("repo_open_issues", float64(len(r.Issues)), r.FullName)
g("repo_open_prs", float64(len(r.PRs)), r.FullName)
var failing int
var oldest time.Duration
for _, pr := range r.PRs {
if pr.Draft {
continue
}
age := v.Now.Sub(pr.CreatedAt)
oldest = max(oldest, age)
num := strconv.Itoa(pr.Number)
g("pr_age_seconds", age.Seconds(), r.FullName, num)
g("pr_checks_failing", b2f(pr.Checks.State == model.ChecksFail), r.FullName, num)
if pr.Checks.State == model.ChecksFail {
failing++
}
}
g("repo_open_prs_failing_checks", float64(failing), r.FullName)
g("repo_oldest_pr_age_seconds", oldest.Seconds(), r.FullName)
var stale int
if v.Thresholds.IssueStaleAfter > 0 {
for _, is := range r.Issues {
if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter {
stale++
}
}
}
g("repo_stale_issues", float64(stale), r.FullName)
seen := map[string]bool{}
for _, wf := range r.Workflows {
// Two workflows may share a name; duplicate label sets would fail the whole scrape.
if seen[wf.Name] {
continue
}
seen[wf.Name] = true
bg := strconv.FormatBool(wf.Background)
g("workflow_failing", b2f(wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds)), r.FullName, wf.Name, bg)
g("workflow_running", b2f(wf.Latest.Running()), r.FullName, wf.Name, bg)
g("workflow_last_run_timestamp_seconds", unix(wf.Latest.CreatedAt), r.FullName, wf.Name, bg)
}
if rel := r.Release; rel != nil {
g("release_info", 1, r.FullName, rel.Tag, rel.URL)
if !rel.PublishedAt.IsZero() {
g("release_published_timestamp_seconds", unix(rel.PublishedAt), r.FullName)
}
}
}
func b2f(b bool) float64 {
if b {
return 1
}
return 0
}
func unix(t time.Time) float64 {
if t.IsZero() {
return 0
}
return float64(t.Unix())
}
+133
View File
@@ -0,0 +1,133 @@
package metrics
import (
"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"
)
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
type fakeSource struct {
st collector.Stats
v collector.View
}
func (f fakeSource) View() collector.View { return f.v }
func (f fakeSource) Stats() collector.Stats { return f.st }
func source() fakeSource {
failed := &model.Run{Status: "completed", Conclusion: "failure"}
repo := model.Repo{
FullName: "o/r", Up: true, Health: model.Crit, RefreshedAt: now.Add(-time.Hour),
Issues: []model.Issue{{UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {UpdatedAt: now}},
PRs: []model.PullRequest{
{Number: 7, CreatedAt: now.Add(-50 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}},
{Number: 8, Draft: true, CreatedAt: now.Add(-900 * time.Hour)},
},
Workflows: []model.Workflow{
{Name: "CI", Latest: *failed, LastDone: failed},
{Name: "CI", Latest: *failed, LastDone: failed}, // duplicate name must not break the scrape
{Name: "Nightly", Background: true, Latest: model.Run{Status: "in_progress", CreatedAt: now}},
},
Release: &model.Release{Tag: "v1.0.0", URL: "https://x/rel", PublishedAt: now.Add(-24 * time.Hour)},
}
return fakeSource{
v: collector.View{Now: now, Repos: []model.Repo{repo}, Crit: 1, Warn: 2,
Thresholds: config.Thresholds{IssueStaleAfter: 30 * 24 * time.Hour}},
st: collector.Stats{LastRefresh: now.Add(-time.Hour), LastDuration: 3 * time.Second, RefreshTotal: 4, RepoErrorsTotal: 1, Loaded: true},
}
}
func TestScrapeValuesAndLint(t *testing.T) {
reg := prometheus.NewPedanticRegistry()
reg.MustRegister(New(source(), func() (int, bool) { return 4321, true }))
want := `
# HELP ghtracker_repo_up 1 if the last refresh of the repo succeeded.
# TYPE ghtracker_repo_up gauge
ghtracker_repo_up{repo="o/r"} 1
# HELP ghtracker_repo_open_prs_failing_checks Open non-draft pull requests whose checks fail.
# TYPE ghtracker_repo_open_prs_failing_checks gauge
ghtracker_repo_open_prs_failing_checks{repo="o/r"} 1
# HELP ghtracker_repo_oldest_pr_age_seconds Age of the oldest open non-draft pull request; 0 if none.
# TYPE ghtracker_repo_oldest_pr_age_seconds gauge
ghtracker_repo_oldest_pr_age_seconds{repo="o/r"} 180000
# HELP ghtracker_repo_stale_issues Open issues without activity for longer than thresholds.issue_stale_after.
# TYPE ghtracker_repo_stale_issues gauge
ghtracker_repo_stale_issues{repo="o/r"} 1
# HELP ghtracker_github_rate_limit_remaining Remaining GitHub API requests in the current window.
# TYPE ghtracker_github_rate_limit_remaining gauge
ghtracker_github_rate_limit_remaining 4321
`
if err := testutil.GatherAndCompare(reg, strings.NewReader(want),
"ghtracker_repo_up", "ghtracker_repo_open_prs_failing_checks", "ghtracker_repo_oldest_pr_age_seconds",
"ghtracker_repo_stale_issues", "ghtracker_github_rate_limit_remaining"); err != nil {
t.Fatal(err)
}
if probs, err := testutil.GatherAndLint(reg); err != nil || len(probs) != 0 {
t.Fatalf("lint: %v %v", probs, err)
}
}
func TestDraftPRsExcludedFromAgeSeries(t *testing.T) {
reg := prometheus.NewPedanticRegistry()
reg.MustRegister(New(source(), nil))
n, err := testutil.GatherAndCount(reg, "ghtracker_pr_age_seconds")
if err != nil || n != 1 {
t.Fatalf("pr_age series = %d err=%v, want 1 (draft excluded)", n, err)
}
}
func TestWorkflowSeriesDedupedAndLabelled(t *testing.T) {
reg := prometheus.NewPedanticRegistry()
reg.MustRegister(New(source(), nil))
want := `
# HELP ghtracker_workflow_failing 1 if the newest completed default-branch run of the workflow failed.
# TYPE ghtracker_workflow_failing gauge
ghtracker_workflow_failing{background="false",repo="o/r",workflow="CI"} 1
ghtracker_workflow_failing{background="true",repo="o/r",workflow="Nightly"} 0
`
if err := testutil.GatherAndCompare(reg, strings.NewReader(want), "ghtracker_workflow_failing"); err != nil {
t.Fatal(err)
}
}
func TestReleaseAndCounters(t *testing.T) {
reg := prometheus.NewPedanticRegistry()
reg.MustRegister(New(source(), nil))
want := `
# HELP ghtracker_release_info Latest release or tag; value is always 1.
# TYPE ghtracker_release_info gauge
ghtracker_release_info{repo="o/r",url="https://x/rel",version="v1.0.0"} 1
# HELP ghtracker_refreshes_total Completed refresh cycles.
# TYPE ghtracker_refreshes_total counter
ghtracker_refreshes_total 4
# HELP ghtracker_repo_refresh_errors_total Repo refresh failures.
# TYPE ghtracker_repo_refresh_errors_total counter
ghtracker_repo_refresh_errors_total 1
# HELP ghtracker_alerts Active alerts by severity.
# TYPE ghtracker_alerts gauge
ghtracker_alerts{severity="crit"} 1
ghtracker_alerts{severity="warn"} 2
`
if err := testutil.GatherAndCompare(reg, strings.NewReader(want),
"ghtracker_release_info", "ghtracker_refreshes_total", "ghtracker_repo_refresh_errors_total", "ghtracker_alerts"); err != nil {
t.Fatal(err)
}
}
func TestRateLimitOmittedWhenUnknown(t *testing.T) {
reg := prometheus.NewPedanticRegistry()
reg.MustRegister(New(source(), func() (int, bool) { return 0, false }))
n, err := testutil.GatherAndCount(reg, "ghtracker_github_rate_limit_remaining")
if err != nil || n != 0 {
t.Fatalf("n=%d err=%v", n, err)
}
}
+146
View File
@@ -0,0 +1,146 @@
// Package model holds the data shapes shared by the collector, metrics and web layers.
package model
import (
"errors"
"time"
)
// ErrRateLimited means GitHub limits were hit or are about to be; callers must stop issuing requests.
var ErrRateLimited = errors.New("github rate limit reached")
type Severity int
const (
OK Severity = iota
Warn
Crit
)
func (s Severity) String() string {
switch s {
case Warn:
return "warn"
case Crit:
return "crit"
default:
return "ok"
}
}
type CheckState string
const (
ChecksNone CheckState = "none"
ChecksPending CheckState = "pending"
ChecksPass CheckState = "success"
ChecksFail CheckState = "failure"
ChecksUnknown CheckState = "unknown"
)
type Repo struct {
RefreshedAt time.Time
Release *Release
Description string
FullName string
DefaultBranch string
ReleasesURL string
// Err is set when the last refresh failed entirely; the other fields hold the last good data.
Err string
URL string
Issues []Issue
PRs []PullRequest
Workflows []Workflow
// Warnings lists sections that failed to load while the rest succeeded.
Warnings []string
Health Severity
Archived bool
Up bool
}
type Release struct {
PublishedAt time.Time
Tag string
Name string
URL string
Prerelease bool
// FromTag marks a version taken from tags because the repo has no releases.
FromTag bool
}
type Issue struct {
CreatedAt time.Time
UpdatedAt time.Time
Title string
URL string
Author string
Labels []string
Number int
Comments int
}
type PullRequest struct {
CreatedAt time.Time
UpdatedAt time.Time
Title string
URL string
Author string
HeadSHA string
Labels []string
Checks CheckSummary
Number int
Draft bool
}
type CheckSummary struct {
State CheckState
Failing []string
Total int
Failed int
Pending int
}
type Workflow struct {
// LastDone is the newest completed run; nil when no run has completed yet.
LastDone *Run
Name string
URL string
Latest Run
Background bool
}
type Run struct {
CreatedAt time.Time
UpdatedAt time.Time
URL string
Event string
Branch string
Status string
Conclusion string
ID int64
}
// Running reports whether the run has not completed.
func (r Run) Running() bool { return r.Status != "completed" }
// Failed reports whether a completed run ended in a failing conclusion.
func (r Run) Failed() bool {
switch r.Conclusion {
case "failure", "timed_out", "startup_failure", "action_required":
return true
}
return false
}
type Alert struct {
Since time.Time
Repo string
Kind string
Subject string
Detail string
URL string
Severity Severity
}
// Message renders the alert as one line for logs and the JSON API.
func (a Alert) Message() string { return a.Subject + ": " + a.Detail }
+174
View File
@@ -0,0 +1,174 @@
package web
import (
"time"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
// 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"`
Repos []apiRepo `json:"repos"`
Alerts []apiAlert `json:"alerts"`
Thresholds apiThresholds `json:"thresholds"`
Counts apiCounts `json:"counts"`
IntervalSeconds int64 `json:"intervalSeconds"`
Loaded bool `json:"loaded"`
Refreshing bool `json:"refreshing"`
ManualRefresh bool `json:"manualRefresh"`
}
type apiThresholds struct {
PRWarnSeconds int64 `json:"prWarnSeconds"`
PRCritSeconds int64 `json:"prCritSeconds"`
IssueStaleSecs int64 `json:"issueStaleSeconds"`
WorkflowStuck int64 `json:"workflowStuckSeconds"`
WorkflowDormant int64 `json:"workflowDormantSeconds"`
}
type apiCounts struct {
Crit int `json:"crit"`
Warn int `json:"warn"`
}
type apiRepo struct {
RefreshedAt time.Time `json:"refreshedAt"`
Release *apiRelease `json:"release"`
FullName string `json:"fullName"`
URL string `json:"url"`
ReleasesURL string `json:"releasesUrl"`
Description string `json:"description"`
Health string `json:"health"`
Error string `json:"error"`
Workflows apiWorkflows `json:"workflows"`
PRs apiPRs `json:"prs"`
Issues apiIssues `json:"issues"`
Archived bool `json:"archived"`
Up bool `json:"up"`
}
type apiWorkflows struct {
Total int `json:"total"`
Failing int `json:"failing"`
Running int `json:"running"`
}
type apiPRs struct {
OldestSeconds int64 `json:"oldestSeconds"`
Open int `json:"open"`
Waiting int `json:"waiting"`
FailingChecks int `json:"failingChecks"`
}
type apiIssues struct {
Open int `json:"open"`
Stale int `json:"stale"`
}
type apiRelease struct {
PublishedAt time.Time `json:"publishedAt"`
Tag string `json:"tag"`
URL string `json:"url"`
Prerelease bool `json:"prerelease"`
FromTag bool `json:"fromTag"`
}
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"`
}
func seconds(d time.Duration) int64 { return int64(d / time.Second) }
func buildState(v collector.View, manualRefresh bool) apiState {
st := apiState{
Now: v.Now,
LastRefresh: v.LastRefresh,
IntervalSeconds: seconds(v.Interval),
Loaded: v.Loaded,
Refreshing: v.Refreshing,
ManualRefresh: manualRefresh,
Counts: apiCounts{Crit: v.Crit, Warn: v.Warn},
Thresholds: apiThresholds{
PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter),
PRCritSeconds: seconds(v.Thresholds.PRCritAfter),
IssueStaleSecs: seconds(v.Thresholds.IssueStaleAfter),
WorkflowStuck: seconds(v.Thresholds.WorkflowStuck),
WorkflowDormant: seconds(v.Thresholds.WorkflowDormant),
},
Repos: make([]apiRepo, 0, len(v.Repos)),
Alerts: make([]apiAlert, 0, len(v.Alerts)),
}
for _, r := range v.Repos {
ar := apiRepo{
FullName: r.FullName, URL: r.URL, ReleasesURL: r.ReleasesURL, Description: r.Description,
Health: r.Health.String(), Error: r.Err, Archived: r.Archived, Up: r.Up, RefreshedAt: r.RefreshedAt,
Workflows: workflowStats(r, v), PRs: prStats(r, v), Issues: issueStats(r, v),
}
if rel := r.Release; rel != nil {
ar.Release = &apiRelease{Tag: rel.Tag, URL: rel.URL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease, FromTag: rel.FromTag}
}
st.Repos = append(st.Repos, ar)
}
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,
})
}
return st
}
func workflowStats(r model.Repo, v collector.View) apiWorkflows {
out := apiWorkflows{Total: len(r.Workflows)}
for _, wf := range r.Workflows {
if wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds) {
out.Failing++
}
if wf.Latest.Running() {
out.Running++
}
}
return out
}
// prStats counts non-draft pull requests; drafts never wait on anyone.
func prStats(r model.Repo, v collector.View) apiPRs {
out := apiPRs{Open: len(r.PRs)}
for _, pr := range r.PRs {
if pr.Draft {
continue
}
age := v.Now.Sub(pr.CreatedAt)
out.OldestSeconds = max(out.OldestSeconds, seconds(age))
if v.Thresholds.PRWarnAfter > 0 && age >= v.Thresholds.PRWarnAfter {
out.Waiting++
}
if pr.Checks.State == model.ChecksFail {
out.FailingChecks++
}
}
return out
}
func issueStats(r model.Repo, v collector.View) apiIssues {
out := apiIssues{Open: len(r.Issues)}
if v.Thresholds.IssueStaleAfter <= 0 {
return out
}
for _, is := range r.Issues {
if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter {
out.Stale++
}
}
return out
}
+129
View File
@@ -0,0 +1,129 @@
// Package web serves the embedded UI, the JSON state, the health probe and metrics.
package web
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"path"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/ghtracker/internal/collector"
)
const (
minManualRefreshGap = 5 * time.Minute
indexFile = "index.html"
assetsPrefix = "/assets/"
immutableCache = "public, max-age=31536000, immutable"
contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'"
)
// Source supplies dashboard state and accepts refresh requests.
type Source interface {
View() collector.View
Stats() collector.Stats
RequestRefresh() bool
}
type Server struct {
lastManual time.Time
now func() time.Time
src Source
ui fs.FS
metrics http.Handler
log *slog.Logger
mu sync.Mutex
manualRefresh bool
}
// 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}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /assets/", s.asset)
mux.HandleFunc("GET /api/state", s.state)
mux.HandleFunc("GET /healthz", s.health)
mux.Handle("GET /metrics", s.metrics)
if s.manualRefresh {
mux.HandleFunc("POST /refresh", s.refresh)
}
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurity)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
b, err := fs.ReadFile(s.ui, indexFile)
if err != nil {
http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(b)
}
// asset serves hashed build output under /assets/ from the embedded filesystem.
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if !strings.HasPrefix("/"+name, assetsPrefix) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", immutableCache)
http.ServeFileFS(w, r, s.ui, name)
}
func (s *Server) state(w http.ResponseWriter, _ *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 {
s.log.Error("encode state", "err", err)
}
}
// health reports 503 until the first refresh finishes, for probes.
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
if !s.src.Stats().Loaded {
http.Error(w, "loading", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ok\n"))
}
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" {
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
return
}
s.mu.Lock()
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
if !tooSoon {
s.lastManual = s.now()
}
s.mu.Unlock()
if tooSoon {
http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests)
return
}
s.src.RequestRefresh()
w.WriteHeader(http.StatusAccepted)
}
+239
View File
@@ -0,0 +1,239 @@
package web
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/model"
)
var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
type fakeSource struct {
st collector.Stats
v collector.View
refreshed int
}
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 testView() collector.View {
repo := model.Repo{
FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true,
Health: model.Crit, RefreshedAt: now.Add(-time.Hour), Description: "desc",
Release: &model.Release{Tag: "v1.2.3", URL: "https://github.com/o/r/releases/tag/v1.2.3", PublishedAt: now.Add(-48 * time.Hour)},
PRs: []model.PullRequest{
{Number: 5, CreatedAt: now.Add(-72 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}},
{Number: 6, Draft: true, CreatedAt: now.Add(-900 * time.Hour)},
},
Issues: []model.Issue{{Number: 9, UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {Number: 10, UpdatedAt: now}},
Workflows: []model.Workflow{
{Name: "CI", Latest: model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}, LastDone: &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}},
{Name: "Nightly", Latest: model.Run{Status: "in_progress", CreatedAt: now}},
},
}
bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"}
return collector.View{
Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1,
Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour},
Repos: []model.Repo{repo, bare},
Alerts: []model.Alert{
{Severity: model.Crit, Repo: "o/r", Kind: "workflow_failed", Subject: "CI", Detail: "failure on main (push)", URL: "https://github.com/o/r/actions/runs/1", Since: now.Add(-2 * time.Hour)},
{Severity: model.Warn, Repo: "o/r", Kind: "pr_waiting", Subject: "#5 fix", Detail: "open 3d", URL: "https://github.com/o/r/pull/5", Since: now.Add(-72 * time.Hour)},
},
}
}
func testUI() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><title>ui</title>")},
"assets/app.js": {Data: []byte("console.log(1)")},
"secret.txt": {Data: []byte("nope")},
}
}
func newServer(src *fakeSource, ui fstest.MapFS, manual bool) *Server {
return New(src, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("metrics")) }),
ui, manual, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func do(h http.Handler, method, path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
return rec
}
func TestIndexServesBuiltUIWithSecurityHeaders(t *testing.T) {
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<title>ui</title>") {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-cache" || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("headers: %v", rec.Header())
}
csp := rec.Header().Get("Content-Security-Policy")
for _, want := range []string{"default-src 'none'", "script-src 'self'", "connect-src 'self'"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP %q missing %q", csp, want)
}
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("nosniff missing")
}
}
func TestIndexWithoutBuiltUIIs503(t *testing.T) {
rec := do(newServer(&fakeSource{}, fstest.MapFS{}, false).Handler(), "GET", "/")
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "pnpm build") {
t.Fatalf("%d %s", rec.Code, rec.Body.String())
}
}
func TestAssetsCachedAndScopedToAssetsDir(t *testing.T) {
h := newServer(&fakeSource{}, testUI(), false).Handler()
rec := do(h, "GET", "/assets/app.js")
if rec.Code != 200 || rec.Header().Get("Cache-Control") != immutableCache || rec.Body.String() != "console.log(1)" {
t.Fatalf("%d %v", rec.Code, rec.Header())
}
for _, p := range []string{"/assets/missing.js", "/secret.txt", "/assets/../secret.txt", "/nope"} {
if rec := do(h, "GET", p); rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusTemporaryRedirect {
t.Errorf("%s -> %d, want 404", p, rec.Code)
} else if rec.Body.Len() > 0 && strings.Contains(rec.Body.String(), "nope") {
t.Errorf("%s leaked a file outside assets", p)
}
}
}
func TestStateJSONContract(t *testing.T) {
src := &fakeSource{v: testView()}
rec := do(newServer(src, testUI(), true).Handler(), "GET", "/api/state")
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("%d %v", rec.Code, rec.Header())
}
var got struct {
Repos []map[string]any
Alerts []map[string]any
Counts struct{ Crit, Warn int }
Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 }
IntervalSeconds int64
Loaded bool
ManualRefresh bool
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 {
t.Fatalf("top level: %+v", got)
}
if got.Thresholds.PRWarnSeconds != 172800 || got.Thresholds.PRCritSeconds != 604800 {
t.Fatalf("thresholds: %+v", got.Thresholds)
}
r := got.Repos[0]
if r["fullName"] != "o/r" || r["health"] != "crit" {
t.Fatalf("repo: %v", r)
}
prs, issues, wf := r["prs"].(map[string]any), r["issues"].(map[string]any), r["workflows"].(map[string]any)
if prs["open"] != float64(2) || prs["waiting"] != float64(1) || prs["failingChecks"] != float64(1) || prs["oldestSeconds"] != float64(72*3600) {
t.Fatalf("prs: %v", prs)
}
if issues["open"] != float64(2) || issues["stale"] != float64(1) {
t.Fatalf("issues: %v", issues)
}
if wf["total"] != float64(2) || wf["failing"] != float64(1) || wf["running"] != float64(1) {
t.Fatalf("workflows: %v", wf)
}
if rel := r["release"].(map[string]any); rel["tag"] != "v1.2.3" || rel["url"] == "" {
t.Fatalf("release: %v", rel)
}
if got.Repos[1]["release"] != nil || got.Repos[1]["error"] != "boom" || got.Repos[1]["health"] != "ok" {
t.Fatalf("bare repo: %v", got.Repos[1])
}
if len(got.Alerts) != 2 || got.Alerts[0]["severity"] != "crit" || got.Alerts[0]["subject"] != "CI" {
t.Fatalf("alerts: %v", got.Alerts)
}
}
func TestStateJSONEmptyListsAreArrays(t *testing.T) {
rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/api/state")
body := rec.Body.String()
if !strings.Contains(body, `"repos":[]`) || !strings.Contains(body, `"alerts":[]`) {
t.Fatalf("lists must encode as [], got %s", body)
}
}
func TestHealthzAndMetrics(t *testing.T) {
src := &fakeSource{}
h := newServer(src, testUI(), false).Handler()
if rec := do(h, "GET", "/healthz"); rec.Code != http.StatusServiceUnavailable {
t.Fatalf("before first refresh: %d", rec.Code)
}
src.st.Loaded = true
if rec := do(h, "GET", "/healthz"); rec.Code != 200 {
t.Fatalf("after refresh: %d", rec.Code)
}
if rec := do(h, "GET", "/metrics"); rec.Body.String() != "metrics" {
t.Fatal("metrics handler not mounted")
}
}
func TestManualRefreshDisabledByDefault(t *testing.T) {
src := &fakeSource{}
rec := do(newServer(src, testUI(), false).Handler(), "POST", "/refresh")
if rec.Code == http.StatusAccepted || src.refreshed != 0 {
t.Fatalf("refresh must not be routed when disabled: %d", rec.Code)
}
}
func TestManualRefreshRateLimited(t *testing.T) {
src := &fakeSource{}
s := newServer(src, testUI(), true)
clock := now
s.now = func() time.Time { return clock }
h := s.Handler()
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted {
t.Fatalf("first: %d", rec.Code)
}
clock = clock.Add(time.Minute)
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusTooManyRequests {
t.Fatalf("second within gap: %d", rec.Code)
}
clock = clock.Add(5 * time.Minute)
if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted || src.refreshed != 2 {
t.Fatalf("after gap: %d refreshed=%d", rec.Code, src.refreshed)
}
if rec := do(h, "GET", "/refresh"); rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /refresh: %d", rec.Code)
}
}
func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) {
src := &fakeSource{}
h := newServer(src, testUI(), true).Handler()
for _, site := range []string{"cross-site", "same-site"} {
req := httptest.NewRequest("POST", "/refresh", nil)
req.Header.Set("Sec-Fetch-Site", site)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || src.refreshed != 0 {
t.Fatalf("%s: %d refreshed=%d", site, rec.Code, src.refreshed)
}
}
req := httptest.NewRequest("POST", "/refresh", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("same-origin: %d", rec.Code)
}
}
+101
View File
@@ -0,0 +1,101 @@
// Command ghtracker serves a dashboard and Prometheus metrics for a list of GitHub repositories.
package main
import (
"context"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/lukaszraczylo/ghtracker/internal/collector"
"github.com/lukaszraczylo/ghtracker/internal/config"
"github.com/lukaszraczylo/ghtracker/internal/gh"
"github.com/lukaszraczylo/ghtracker/internal/metrics"
"github.com/lukaszraczylo/ghtracker/internal/web"
)
// distFS holds the built UI; dist/.gitkeep keeps the pattern valid before the first UI build.
//
//go:embed all:dist
var distFS embed.FS
// version is set at release time with -ldflags "-X main.version=...".
var version = "dev"
const (
httpTimeout = 30 * time.Second
shutdownTimeout = 10 * time.Second
headerTimeout = 10 * time.Second
)
func main() {
cfgPath := flag.String("config", "config.yaml", "path to the YAML config file")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, *cfgPath, log); err != nil {
stop()
log.Error("fatal", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context, cfgPath string, log *slog.Logger) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
hc := &http.Client{Timeout: httpTimeout}
auth, err := gh.NewAppAuth(cfg.GitHub.AppID, cfg.GitHub.PrivateKeyPEM, cfg.GitHub.APIURL, cfg.GitHub.InstallationID, hc)
if err != nil {
return err
}
client := gh.NewClient(cfg.GitHub.APIURL, auth, hc, cfg.MaxItems)
coll := collector.New(client, cfg, log)
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
metrics.New(coll, client.RateLimitRemaining))
ui, err := fs.Sub(distFS, "dist")
if err != nil {
return err
}
srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log)
go coll.Run(ctx)
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout}
errc := make(chan error, 1)
go func() { errc <- hs.ListenAndServe() }()
log.Info("listening", "version", version, "addr", cfg.Listen, "repos", len(cfg.Repos), "refresh", cfg.RefreshInterval)
select {
case err := <-errc:
return err
case <-ctx.Done():
}
sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := hs.Shutdown(sctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func freeAddr(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().String()
}
func get(t *testing.T, url string) (int, string) {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, ""
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
// TestEndToEnd wires config, app auth, collector, metrics and web against a fake GitHub API.
func TestEndToEnd(t *testing.T) {
var apiCalls atomic.Int64
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiCalls.Add(1)
p := r.URL.Path
switch {
case strings.HasSuffix(p, "/installation"):
fmt.Fprint(w, `{"id":1}`)
case strings.HasPrefix(p, "/app/installations/"):
fmt.Fprintf(w, `{"token":"t","expires_at":%q}`, time.Now().Add(time.Hour).UTC().Format(time.RFC3339))
case p == "/repos/o/r":
fmt.Fprint(w, `{"html_url":"https://github.com/o/r","default_branch":"main"}`)
case p == "/repos/o/r/issues", p == "/repos/o/r/pulls", p == "/repos/o/r/tags":
fmt.Fprint(w, `[]`)
case p == "/repos/o/r/actions/workflows":
fmt.Fprint(w, `{"workflows":[{"id":1,"name":"CI","html_url":"w","state":"active"}]}`)
case p == "/repos/o/r/actions/workflows/1/runs":
fmt.Fprint(w, `{"workflow_runs":[{"id":1,"html_url":"r","event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-01T00:00:00Z"}]}`)
case p == "/repos/o/r/releases/latest":
fmt.Fprint(w, `{"tag_name":"v9.9.9","html_url":"rel","published_at":"2026-10-01T00:00:00Z"}`)
default:
http.NotFound(w, r)
}
}))
defer gh.Close()
key, _ := rsa.GenerateKey(rand.Reader, 2048)
dir := t.TempDir()
keyPath := filepath.Join(dir, "k.pem")
_ = os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0o600)
addr := freeAddr(t)
cfgPath := filepath.Join(dir, "c.yaml")
cfg := fmt.Sprintf("listen: %q\ngithub:\n app_id: 1\n private_key_file: %s\n api_url: %s\nrepos: [o/r]\n", addr, keyPath, gh.URL)
_ = os.WriteFile(cfgPath, []byte(cfg), 0o600)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- run(ctx, cfgPath, slog.New(slog.NewTextHandler(io.Discard, nil))) }()
base := "http://" + addr
deadline := time.Now().Add(10 * time.Second)
for {
if code, _ := get(t, base+"/healthz"); code == 200 {
break
}
if time.Now().After(deadline) {
t.Fatal("server never became healthy")
}
time.Sleep(20 * time.Millisecond)
}
_, metrics := get(t, base+"/metrics")
for _, want := range []string{
`ghtracker_repo_up{repo="o/r"} 1`,
`ghtracker_workflow_failing{background="false",repo="o/r",workflow="CI"} 1`,
`ghtracker_release_info{repo="o/r",url="rel",version="v9.9.9"} 1`,
`ghtracker_repo_health{repo="o/r"} 2`,
`ghtracker_alerts{severity="crit"} 1`,
"ghtracker_refreshes_total 1",
} {
if !strings.Contains(metrics, want) {
t.Errorf("metrics missing %q", want)
}
}
_, state := get(t, base+"/api/state")
for _, want := range []string{`"tag":"v9.9.9"`, `"failure on main (push)"`, `"health":"crit"`} {
if !strings.Contains(state, want) {
t.Errorf("state missing %q", want)
}
}
if n := apiCalls.Load(); n > 12 {
t.Errorf("one refresh of one repo used %d API calls, want a handful", n)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("shutdown: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("no graceful shutdown")
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "ghtracker-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && node scripts/clean-dist.mjs && vite build",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource-variable/schibsted-grotesk": "5.3.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/vue-fontawesome": "^3.3.3",
"@lucide/vue": "^1.49.0",
"@vueuse/core": "^15.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^4.0.3",
"reka-ui": "^2.10.5",
"tailwind-merge": "^3.7.0",
"vue": "^3.5.43"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.6.2",
"@vitejs/plugin-vue": "^6.0.9",
"@vue/test-utils": "^2.5.1",
"eslint": "^10.11.0",
"eslint-plugin-vue": "^10.11.1",
"globals": "^17.12.0",
"happy-dom": "^20.14.5",
"prettier": "^3.9.9",
"tailwindcss": "^4.3.3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.70.1",
"vite": "^8.3.1",
"vitest": "^5.0.1",
"vue-tsc": "^3.3.11"
},
"packageManager": "[email protected]"
}
+3623
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
vue-demi: false
+8
View File
@@ -0,0 +1,8 @@
// Empties dist/ but keeps .gitkeep, which lets `go build` embed dist/ before the first UI build.
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
mkdirSync('dist', { recursive: true })
for (const name of readdirSync('dist')) {
if (name !== '.gitkeep') rmSync(`dist/${name}`, { recursive: true, force: true })
}
writeFileSync('dist/.gitkeep', '')
+18
View File
@@ -0,0 +1,18 @@
# Read by the shared release workflow (lukaszraczylo/semver-generator).
version: 1
force:
existing: true
strict: false
# Fuzzy matching counts stray words such as "majority" as a major bump.
# Only a `Semver-Major:` trailer line may start a major release.
matching: regex
wording:
patch:
- '(?m)^(fix|perf|refactor|revert|build|chore|ci|docs|style|test)(\([^)]*\))?!?:'
minor:
- '(?m)^feat(\([^)]*\))?!?:'
major:
- '(?m)^Semver-Major:'
+91
View File
@@ -0,0 +1,91 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
import { mixedState, repo, state } from '@/test/fixtures'
import type { TrackerState } from '@/lib/types'
function mountWith(body: TrackerState | Error) {
vi.stubGlobal(
'fetch',
vi.fn(() =>
body instanceof Error
? Promise.reject(body)
: Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
),
)
return mount(App, { global: { plugins: [createPinia()] } })
}
describe('App', () => {
afterEach(() => vi.unstubAllGlobals())
it('shows the overview, only the problems and every project', async () => {
const w = mountWith(mixedState())
await flushPromises()
expect(w.get('[data-test=headline]').text()).toContain('2')
expect(w.findAll('[data-test=segment]')).toHaveLength(2)
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=row]')).toHaveLength(2)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('shows an all-clear state without repo blocks', async () => {
const w = mountWith(state({ repos: [repo()] }))
await flushPromises()
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.findAll('[data-test=project]')).toHaveLength(1)
})
it('focuses one project from the strip and clears the focus', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.findAll('[data-test=segment]')[1].trigger('click')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=empty]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('narrows the list as you type and restores it when cleared', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=search]').setValue('zzz-no-match')
expect(w.find('[data-test=repo-block]').exists()).toBe(false)
expect(w.find('[data-test=no-projects]').exists()).toBe(true)
await w.get('[data-test=clear-all]').trigger('click')
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
expect(w.findAll('[data-test=project]')).toHaveLength(2)
})
it('applies a kind filter from the toolbar', async () => {
const w = mountWith(mixedState())
await flushPromises()
await w.get('[data-test=kind-workflow]').trigger('click')
expect(w.findAll('[data-test=row]')).toHaveLength(1)
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
})
it('waits while the server has not finished its first refresh', async () => {
const w = mountWith(state({ loaded: false }))
await flushPromises()
expect(w.find('[data-test=waiting]').exists()).toBe(true)
expect(w.find('[data-test=headline]').exists()).toBe(false)
})
it('surfaces a connection error', async () => {
const w = mountWith(new Error('offline'))
await flushPromises()
expect(w.get('[data-test=error]').text()).toContain('offline')
})
it('offers a rescan only when the server allows it', async () => {
const on = mountWith(state({ manualRefresh: true, repos: [repo()] }))
await flushPromises()
expect(on.find('[data-test=rescan]').exists()).toBe(true)
const off = mountWith(state({ manualRefresh: false, repos: [repo()] }))
await flushPromises()
expect(off.find('[data-test=rescan]').exists()).toBe(false)
})
})
+134
View File
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AttentionList from '@/components/AttentionList.vue'
import FleetOverview from '@/components/FleetOverview.vue'
import ProjectList from '@/components/ProjectList.vue'
import { filtersFromSearch, filtersToSearch } from '@/lib/filters'
import { useTrackerStore } from '@/stores/tracker'
const POLL_MS = 60_000
const CLOCK_MS = 30_000
const store = useTrackerStore()
const {
state,
error,
notice,
scanning,
blocks,
visibleBlocks,
nowMs,
filters,
facets,
owners,
visibleRepos,
activeFilters,
repoCounts,
facts,
} = storeToRefs(store)
const list = ref<InstanceType<typeof AttentionList> | null>(null)
const matching = computed(() => new Set(visibleRepos.value.map((r) => r.fullName)))
let timers: number[] = []
/** Pressing "/" outside a text field jumps to the search box. */
function onKey(e: KeyboardEvent): void {
const el = e.target as HTMLElement | null
if (e.key !== '/' || e.metaKey || e.ctrlKey || el?.closest('input, textarea, [contenteditable]'))
return
e.preventDefault()
list.value?.focusSearch()
}
// Filters live in the URL so a filtered view can be shared and survives a reload.
watch(
filters,
(f) => window.history.replaceState(null, '', window.location.pathname + filtersToSearch(f)),
{ deep: true },
)
onMounted(() => {
filters.value = filtersFromSearch(window.location.search)
window.addEventListener('keydown', onKey)
void store.load()
timers = [
window.setInterval(() => void store.load(), POLL_MS),
window.setInterval(store.advanceClock, CLOCK_MS),
]
})
onBeforeUnmount(() => {
timers.forEach((t) => window.clearInterval(t))
window.removeEventListener('keydown', onKey)
})
</script>
<template>
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8">
<AppHeader
:last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs"
:interval-seconds="state?.intervalSeconds ?? 0"
:loaded="state?.loaded ?? false"
:scanning="scanning"
:manual-refresh="state?.manualRefresh ?? false"
@rescan="store.rescan()"
/>
<p v-if="error" role="alert" class="text-crit mt-4 text-sm" data-test="error">
Could not reach the server: {{ error }}
</p>
<p v-if="notice" role="status" class="text-warn mt-4 text-sm" data-test="notice">
{{ notice }}
</p>
<p v-if="!state" class="text-muted-foreground mt-16 text-3xl font-semibold">Loading</p>
<p
v-else-if="!state.loaded"
class="text-muted-foreground mt-16 text-3xl font-semibold"
data-test="waiting"
>
Fetching data from GitHub
</p>
<template v-else>
<div class="mt-12">
<FleetOverview
:crit="state.counts.crit"
:warn="state.counts.warn"
:block-count="blocks.length"
:repos="state.repos"
:counts="repoCounts"
:facts="facts!"
:focused="filters.repo"
:matching="matching"
@select="store.toggleRepo"
/>
</div>
<div
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]"
>
<AttentionList
ref="list"
v-model:filters="filters"
:blocks="visibleBlocks"
:now-ms="nowMs"
:crit-seconds="state.thresholds.prCritSeconds"
:facets="facets"
:owners="owners"
:active="activeFilters"
@clear="store.clearFilters()"
/>
<aside class="lg:sticky lg:top-6 lg:self-start">
<ProjectList
:repos="visibleRepos"
:total="state.repos.length"
:focused="filters.repo"
:now-ms="nowMs"
@select="store.toggleRepo"
/>
</aside>
</div>
</template>
</main>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ ratio: number; severity: 'warn' | 'crit' }>()
const SEGMENTS = 10
const filled = computed(() => Math.min(SEGMENTS, Math.max(1, Math.ceil(props.ratio * SEGMENTS))))
</script>
<template>
<span
class="inline-flex gap-px"
role="img"
:aria-label="`${Math.round(Math.min(ratio, 1) * 100)}% of the critical age`"
>
<span
v-for="i in SEGMENTS"
:key="i"
class="h-3 w-1 rounded-[1px]"
:class="i <= filled ? (severity === 'crit' ? 'bg-crit' : 'bg-warn') : 'bg-border'"
/>
</span>
</template>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { faArrowsRotate, faSatelliteDish } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { ageLabel, ageSince, isZeroTime } from '@/lib/format'
const props = defineProps<{
lastRefresh: string
nowMs: number
intervalSeconds: number
loaded: boolean
scanning: boolean
manualRefresh: boolean
}>()
defineEmits<{ rescan: [] }>()
const updated = computed(() =>
props.loaded && !isZeroTime(props.lastRefresh) ? ageSince(props.lastRefresh, props.nowMs) : '',
)
const nextIn = computed(() => {
if (!updated.value) return ''
const left = Math.floor(
(Date.parse(props.lastRefresh) + props.intervalSeconds * 1000 - props.nowMs) / 1000,
)
return left > 0 ? ageLabel(left) : 'now'
})
</script>
<template>
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
>
<FontAwesomeIcon :icon="faSatelliteDish" aria-hidden="true" />
</span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div>
<div class="text-muted-foreground flex items-center gap-5 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated">
Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span>
<span v-else>Loading</span>
<Button
v-if="manualRefresh"
variant="outline"
size="sm"
:disabled="scanning"
data-test="rescan"
@click="$emit('rescan')"
>
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}
</Button>
</div>
</header>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import FilterBar from '@/components/FilterBar.vue'
import RepoGroup from '@/components/RepoGroup.vue'
import type { Facets, Filters } from '@/lib/filters'
import type { Block } from '@/lib/types'
defineProps<{
blocks: Block[]
nowMs: number
critSeconds: number
facets: Facets
owners: string[]
active: number
}>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const bar = ref<InstanceType<typeof FilterBar> | null>(null)
defineExpose({ focusSearch: () => bar.value?.focus() })
</script>
<template>
<section aria-labelledby="attention-title">
<h2 id="attention-title" class="mb-4 text-lg font-semibold tracking-tight">Needs attention</h2>
<FilterBar
ref="bar"
v-model:filters="filters"
:facets="facets"
:owners="owners"
:active="active"
class="mb-5"
@clear="$emit('clear')"
/>
<div
v-if="blocks.length === 0"
class="border-border text-muted-foreground flex items-center gap-3 rounded-lg border border-dashed p-6"
data-test="empty"
>
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters.
</div>
<div v-else class="flex flex-col gap-4">
<RepoGroup
v-for="block in blocks"
:key="block.repo.fullName"
:block="block"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</div>
</section>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AgeMeter from '@/components/AgeMeter.vue'
import { ageSince, isZeroTime, secondsSince, shorten } from '@/lib/format'
import { iconFor } from '@/lib/kinds'
import type { Row } from '@/lib/types'
const props = defineProps<{ row: Row; nowMs: number; critSeconds: number }>()
const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.critSeconds)
</script>
<template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row">
<span
class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
>
<FontAwesomeIcon :icon="iconFor(row.kind)" aria-hidden="true" />
<span class="sr-only">{{ row.severity === 'crit' ? 'Critical' : 'Warning' }}</span>
</span>
<div class="min-w-0">
<a
:href="row.url"
class="hover:text-link leading-snug font-medium wrap-anywhere underline-offset-4 hover:underline"
>
{{ shorten(row.subject) }}
</a>
<p class="text-muted-foreground mt-0.5 text-sm wrap-anywhere">
{{ shorten(row.detail, 120) }}
</p>
</div>
<div class="flex flex-col items-end gap-1.5 pt-0.5">
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span>
<AgeMeter
v-if="row.kind === 'pr_waiting' && !isZeroTime(row.since)"
:ratio="ratio"
:severity="row.severity"
/>
</div>
</li>
</template>
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import {
faCircleDot,
faCodePullRequest,
faGears,
faMagnifyingGlass,
faTriangleExclamation,
faXmark,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { KIND_GROUPS, type Facets, type Filters, type KindGroup } from '@/lib/filters'
import type { SeverityFilter } from '@/lib/filters'
defineProps<{ facets: Facets; owners: string[]; active: number }>()
const filters = defineModel<Filters>('filters', { required: true })
defineEmits<{ clear: [] }>()
const KIND_META: Record<KindGroup, { label: string; icon: IconDefinition }> = {
workflow: { label: 'Workflows', icon: faGears },
pr: { label: 'Pull requests', icon: faCodePullRequest },
issue: { label: 'Issues', icon: faCircleDot },
data: { label: 'Data', icon: faTriangleExclamation },
}
const search = ref<InstanceType<typeof Input> | null>(null)
defineExpose({ focus: () => (search.value?.$el as HTMLInputElement | undefined)?.focus() })
function patch(change: Partial<Filters>): void {
filters.value = { ...filters.value, ...change }
}
function onSeverity(value: unknown): void {
if (value === 'all' || value === 'crit' || value === 'warn')
patch({ severity: value as SeverityFilter })
else patch({ severity: 'all' })
}
function onKinds(value: unknown): void {
patch({ kinds: (Array.isArray(value) ? value : []) as KindGroup[] })
}
function onOwners(value: unknown): void {
patch({ owners: (Array.isArray(value) ? value : []) as string[] })
}
</script>
<template>
<div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1">
<FontAwesomeIcon
:icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref="search"
:model-value="filters.query"
type="search"
placeholder="Search items, projects and versions"
aria-label="Search"
class="bg-surface pr-9 pl-9"
data-test="search"
@update:model-value="patch({ query: String($event ?? '') })"
@keydown.esc="patch({ query: '' })"
/>
<kbd
v-if="!filters.query"
class="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 rounded border px-1.5 font-mono text-[11px]"
aria-hidden="true"
>/</kbd
>
</div>
<ToggleGroup
type="single"
variant="outline"
size="sm"
:model-value="filters.severity"
aria-label="Severity"
@update:model-value="onSeverity"
>
<ToggleGroupItem value="all" data-test="sev-all"
>All {{ facets.severity.all }}</ToggleGroupItem
>
<ToggleGroupItem value="crit" data-test="sev-crit"
>Critical {{ facets.severity.crit }}</ToggleGroupItem
>
<ToggleGroupItem value="warn" data-test="sev-warn"
>Warnings {{ facets.severity.warn }}</ToggleGroupItem
>
</ToggleGroup>
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup
type="multiple"
variant="outline"
size="sm"
:model-value="filters.kinds"
aria-label="Kind"
@update:model-value="onKinds"
>
<ToggleGroupItem
v-for="kind in KIND_GROUPS"
:key="kind"
:value="kind"
:disabled="facets.kinds[kind] === 0 && !filters.kinds.includes(kind)"
:data-test="`kind-${kind}`"
>
<FontAwesomeIcon :icon="KIND_META[kind].icon" aria-hidden="true" />
{{ KIND_META[kind].label }}
<span class="text-muted-foreground tabular-nums">{{ facets.kinds[kind] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
v-if="owners.length > 1"
type="multiple"
variant="outline"
size="sm"
:model-value="filters.owners"
aria-label="Owner"
@update:model-value="onOwners"
>
<ToggleGroupItem
v-for="owner in owners"
:key="owner"
:value="owner"
:disabled="facets.owners[owner] === 0 && !filters.owners.includes(owner)"
:data-test="`owner-${owner}`"
>
{{ owner }}
<span class="text-muted-foreground tabular-nums">{{ facets.owners[owner] }}</span>
</ToggleGroupItem>
</ToggleGroup>
<Button
v-if="active > 0"
variant="ghost"
size="sm"
class="ml-auto"
data-test="clear-all"
@click="$emit('clear')"
>
<FontAwesomeIcon :icon="faXmark" aria-hidden="true" />
Clear {{ active }} {{ active === 1 ? 'filter' : 'filters' }}
</Button>
</div>
</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import {
faCodePullRequest,
faCircleDot,
faGears,
faTriangleExclamation,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { computed } from 'vue'
import FleetStrip from '@/components/FleetStrip.vue'
import type { Repo } from '@/lib/types'
import type { Facts, RepoCounts } from '@/stores/tracker'
const props = defineProps<{
crit: number
warn: number
blockCount: number
repos: Repo[]
counts: Map<string, RepoCounts>
facts: Facts
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const rows = computed<{ label: string; value: number; icon: IconDefinition; tone: string }[]>(
() => [
{
label: 'Workflows failing',
value: props.facts.failingWorkflows,
icon: faGears,
tone: 'text-crit',
},
{
label: 'Pull requests waiting',
value: props.facts.waitingPrs,
icon: faCodePullRequest,
tone: 'text-crit',
},
{
label: 'PRs with failing checks',
value: props.facts.failingChecks,
icon: faCodePullRequest,
tone: 'text-warn',
},
{ label: 'Stale issues', value: props.facts.staleIssues, icon: faCircleDot, tone: 'text-warn' },
{
label: 'Data problems',
value: props.facts.dataProblems,
icon: faTriangleExclamation,
tone: 'text-warn',
},
],
)
</script>
<template>
<section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"
aria-label="Fleet status"
>
<div>
<p class="text-muted-foreground mb-3 text-sm" data-test="scope">
<template v-if="blockCount > 0"
>{{ blockCount }} of {{ repos.length }} projects need attention</template
>
<template v-else>All {{ repos.length }} projects are healthy</template>
</p>
<h1
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 font-semibold tracking-tight"
data-test="headline"
>
<template v-if="crit > 0 || warn > 0">
<span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span>
</span>
<span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span>
</template>
<span v-else class="text-ok text-7xl leading-none">All clear</span>
</h1>
<div class="mt-8">
<FleetStrip
:repos="repos"
:counts="counts"
:focused="focused"
:matching="matching"
@select="$emit('select', $event)"
/>
<p class="text-muted-foreground mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="flex items-center gap-1.5"
><span class="bg-crit size-2 rounded-[2px]"></span>critical</span
>
<span class="flex items-center gap-1.5"
><span class="bg-warn size-2 rounded-[2px]"></span>warning</span
>
<span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
>
<span class="ml-auto">Select a segment to focus a project</span>
</p>
</div>
</div>
<dl class="border-border divide-border divide-y border-y text-sm" data-test="facts">
<div v-for="row in rows" :key="row.label" class="flex items-center gap-3 py-2.5">
<FontAwesomeIcon :icon="row.icon" class="text-muted-foreground w-4" aria-hidden="true" />
<dt class="flex-1">{{ row.label }}</dt>
<dd
class="font-mono text-base tabular-nums"
:class="row.value > 0 ? row.tone : 'text-muted-foreground'"
>
{{ row.value }}
</dd>
</div>
</dl>
</section>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { Repo } from '@/lib/types'
import type { RepoCounts } from '@/stores/tracker'
defineProps<{
repos: Repo[]
counts: Map<string, RepoCounts>
focused: string | null
matching: Set<string>
}>()
defineEmits<{ select: [fullName: string] }>()
const SEGMENT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok/30 hover:bg-ok/60' } as const
function summary(c: RepoCounts | undefined): string {
if (!c) return 'healthy'
const parts = []
if (c.crit) parts.push(`${c.crit} critical`)
if (c.warn) parts.push(`${c.warn} ${c.warn === 1 ? 'warning' : 'warnings'}`)
return parts.join(' · ')
}
</script>
<template>
<TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip">
<Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child>
<button
type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1"
:class="[
SEGMENT[repo.health],
focused === repo.fullName
? 'ring-foreground ring-2 ring-offset-2 ring-offset-background'
: '',
]"
data-test="segment"
@click="$emit('select', repo.fullName)"
/>
</TooltipTrigger>
<TooltipContent>
<p class="font-medium">{{ repo.fullName }}</p>
<p class="opacity-80">{{ summary(counts.get(repo.fullName)) }}</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { faCodePullRequest, faCircleDot, faGears } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { ageSince, isZeroTime } from '@/lib/format'
import type { Repo } from '@/lib/types'
defineProps<{ repos: Repo[]; total: number; focused: string | null; nowMs: number }>()
defineEmits<{ select: [fullName: string] }>()
const DOT = { crit: 'bg-crit', warn: 'bg-warn', ok: 'bg-ok' } as const
const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
</script>
<template>
<section aria-labelledby="projects-title">
<h2 id="projects-title" class="mb-4 text-lg font-semibold tracking-tight">
Projects
<span class="text-muted-foreground text-sm font-normal" data-test="project-count">{{
repos.length === total ? total : `${repos.length} of ${total}`
}}</span>
</h2>
<p
v-if="repos.length === 0"
class="text-muted-foreground border-border rounded-lg border border-dashed p-4 text-sm"
data-test="no-projects"
>
No projects match the search.
</p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border">
<li
v-for="repo in repos"
:key="repo.fullName"
class="px-3 py-2.5"
:class="focused === repo.fullName ? 'bg-accent' : ''"
data-test="project"
>
<div class="flex items-center gap-2.5">
<span class="size-2 shrink-0 rounded-full" :class="DOT[repo.health]"></span>
<span class="sr-only">{{ LABEL[repo.health] }}:</span>
<button
type="button"
class="hover:text-link min-w-0 flex-1 truncate text-left text-sm font-medium"
:aria-pressed="focused === repo.fullName"
@click="$emit('select', repo.fullName)"
>
{{ repo.fullName.split('/')[1] }}
<span class="text-muted-foreground font-normal">{{ repo.fullName.split('/')[0] }}</span>
</button>
<a
v-if="repo.release"
:href="repo.release.url"
class="text-muted-foreground hover:text-link font-mono text-xs"
>
{{ repo.release.tag }}
</a>
<a v-else :href="repo.releasesUrl" class="text-muted-foreground hover:text-link text-xs"
>no release</a
>
</div>
<div
class="text-muted-foreground mt-1 flex items-center gap-4 pl-[18px] text-xs tabular-nums"
>
<a
:href="`${repo.url}/actions`"
class="hover:text-link flex items-center gap-1.5"
:class="repo.workflows.failing ? 'text-crit' : ''"
>
<FontAwesomeIcon :icon="faGears" aria-hidden="true" />
{{
repo.workflows.failing
? `${repo.workflows.failing}/${repo.workflows.total} failing`
: repo.workflows.total
}}
<span class="sr-only">workflows</span>
</a>
<a :href="`${repo.url}/pulls`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCodePullRequest" aria-hidden="true" />{{ repo.prs.open }}
<span class="sr-only">pull requests</span>
</a>
<a :href="`${repo.url}/issues`" class="hover:text-link flex items-center gap-1.5">
<FontAwesomeIcon :icon="faCircleDot" aria-hidden="true" />{{ repo.issues.open }}
<span class="sr-only">issues</span>
</a>
<span v-if="repo.release && !isZeroTime(repo.release.publishedAt)" class="ml-auto">
released {{ ageSince(repo.release.publishedAt, nowMs) }} ago
</span>
</div>
</li>
</ul>
</section>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { faTag } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import AttentionRow from '@/components/AttentionRow.vue'
import { Badge } from '@/components/ui/badge'
import type { Block } from '@/lib/types'
defineProps<{ block: Block; nowMs: number; critSeconds: number }>()
</script>
<template>
<section class="border-border bg-surface rounded-lg border" data-test="repo-block">
<header class="border-border flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-3">
<h3 class="font-semibold">
<a :href="block.repo.url" class="hover:text-link underline-offset-4 hover:underline">
<span class="text-muted-foreground font-normal"
>{{ block.repo.fullName.split('/')[0] }}/</span
>{{ block.repo.fullName.split('/')[1] }}
</a>
</h3>
<Badge v-if="block.repo.release" variant="secondary" as-child class="font-mono">
<a :href="block.repo.release.url">
<FontAwesomeIcon :icon="faTag" aria-hidden="true" class="text-[0.7em]" />
{{ block.repo.release.tag }}
</a>
</Badge>
<span class="text-muted-foreground ml-auto text-xs">
{{ block.rows.length }} {{ block.rows.length === 1 ? 'item' : 'items' }}
</span>
</header>
<ul class="divide-border divide-y px-4">
<AttentionRow
v-for="row in block.rows"
:key="row.url"
:row="row"
:now-ms="nowMs"
:crit-seconds="critSeconds"
/>
</ul>
</section>
</template>
+306
View File
@@ -0,0 +1,306 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AgeMeter from './AgeMeter.vue'
import AppHeader from './AppHeader.vue'
import AttentionList from './AttentionList.vue'
import FilterBar from './FilterBar.vue'
import AttentionRow from './AttentionRow.vue'
import FleetOverview from './FleetOverview.vue'
import ProjectList from './ProjectList.vue'
import RepoGroup from './RepoGroup.vue'
import { activeFilterCount, emptyFilters, facetCounts, type Filters } from '@/lib/filters'
import { buildBlocks, computeFacts, countByRepo } from '@/stores/tracker'
import { hoursAgo, mixedState, NOW, repo } from '@/test/fixtures'
import type { Row } from '@/lib/types'
const nowMs = Date.parse(NOW)
const CRIT_SECONDS = 604800
const text = (w: { text(): string }) => w.text().replace(/\s+/g, ' ')
describe('AppHeader', () => {
const base = {
lastRefresh: '2026-10-02T11:00:00Z',
nowMs,
intervalSeconds: 21600,
loaded: true,
scanning: false,
}
it('shows when data was fetched and when the next refresh is due', () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: false } })
expect(text(w.get('[data-test=updated]'))).toBe('Scanned 1h ago · next in 5h')
expect(w.find('[data-test=rescan]').exists()).toBe(false)
})
it('shows Loading before the first refresh finishes', () => {
const w = mount(AppHeader, { props: { ...base, loaded: false, manualRefresh: false } })
expect(w.text()).toContain('Loading')
})
it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click')
expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…')
})
})
describe('FleetOverview', () => {
const s = mixedState()
const props = {
crit: 2,
warn: 1,
blockCount: 1,
repos: s.repos,
counts: countByRepo(s),
facts: computeFacts(s),
focused: null,
matching: new Set(['o/r', 'o/fine']),
}
it('leads with the critical and warning counts and the scope', () => {
const w = mount(FleetOverview, { props })
const headline = w.get('[data-test=headline]').text()
expect(headline).toMatch(/2\s*critical/)
expect(headline).toMatch(/1\s*warning/)
expect(w.get('[data-test=scope]').text()).toBe('1 of 2 projects need attention')
})
it('lists the headline facts, dimming zeros', () => {
const w = mount(FleetOverview, { props })
const facts = Object.fromEntries(
w.findAll('[data-test=facts] > div').map((d) => [d.get('dt').text(), d.get('dd').text()]),
)
expect(facts).toMatchObject({
'Workflows failing': '1',
'Stale issues': '2',
'Data problems': '0',
})
})
it('says all clear when nothing is wrong', () => {
const w = mount(FleetOverview, { props: { ...props, crit: 0, warn: 0, blockCount: 0 } })
expect(w.get('[data-test=headline]').text()).toBe('All clear')
expect(w.get('[data-test=scope]').text()).toBe('All 2 projects are healthy')
})
it('draws one segment per project, labelled for screen readers, and emits on click', async () => {
const w = mount(FleetOverview, { props: { ...props, focused: 'o/r' } })
const segments = w.findAll('[data-test=segment]')
expect(segments).toHaveLength(2)
expect(segments[0].attributes('aria-label')).toBe('o/r: 2 critical · 1 warning')
expect(segments[0].attributes('aria-pressed')).toBe('true')
expect(segments[1].attributes('aria-label')).toBe('o/fine: healthy')
await segments[1].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/fine'])
})
})
describe('AgeMeter', () => {
it('fills proportionally and always shows at least one segment', () => {
const filled = (ratio: number) =>
mount(AgeMeter, { props: { ratio, severity: 'crit' } })
.findAll('span span')
.filter((s) => s.classes().includes('bg-crit')).length
expect(filled(0)).toBe(1)
expect(filled(0.5)).toBe(5)
expect(filled(3)).toBe(10)
})
it('uses the warning colour for warnings', () => {
const w = mount(AgeMeter, { props: { ratio: 0.2, severity: 'warn' } })
expect(w.find('.bg-warn').exists()).toBe(true)
expect(w.find('.bg-crit').exists()).toBe(false)
})
})
describe('AttentionRow', () => {
const row = (over: Partial<Row> = {}): Row => ({
kind: 'pr_waiting',
subject: '#5 <b>bold</b> title',
detail: 'open 9d',
url: 'https://github.com/o/r/pull/5',
since: hoursAgo(216),
severity: 'crit',
...over,
})
it('links the subject, shows the age and escapes markup', () => {
const w = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(w.get('a').attributes('href')).toBe('https://github.com/o/r/pull/5')
expect(w.text()).toContain('<b>bold</b>')
expect(w.find('b').exists()).toBe(false)
expect(w.text()).toContain('9d')
expect(w.text()).toContain('Critical')
})
it('shows the age meter only for waiting pull requests', () => {
const pr = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } })
expect(pr.find('[aria-label$="critical age"]').exists()).toBe(true)
const wf = mount(AttentionRow, {
props: { row: row({ kind: 'workflow_failed' }), nowMs, critSeconds: CRIT_SECONDS },
})
expect(wf.find('[aria-label$="critical age"]').exists()).toBe(false)
})
it('omits the age for an unknown timestamp', () => {
const w = mount(AttentionRow, {
props: {
row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }),
nowMs,
critSeconds: CRIT_SECONDS,
},
})
expect(w.find('.font-mono').text()).toBe('')
})
})
describe('RepoGroup', () => {
const block = buildBlocks(mixedState())[0]
it('shows the repo, its version link and an item count', () => {
const w = mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.text()).toContain('o/')
expect(w.get('a[href="https://github.com/o/r"]').text()).toContain('r')
expect(w.get('a[href="https://github.com/o/r/releases/tag/v1.0.0"]').text()).toBe('v1.0.0')
expect(w.text()).toContain('2 items')
expect(w.findAll('[data-test=row]')).toHaveLength(2)
})
it('omits the version badge without a release and uses the singular for one item', () => {
const one = { repo: repo({ release: null }), rows: [block.rows[0]] }
const w = mount(RepoGroup, { props: { block: one, nowMs, critSeconds: CRIT_SECONDS } })
expect(w.find('a[href*="/releases/"]').exists()).toBe(false)
expect(w.text()).toContain('1 item')
})
})
describe('AttentionList', () => {
const blocks = buildBlocks(mixedState())
const props = {
blocks,
nowMs,
critSeconds: CRIT_SECONDS,
facets: facetCounts(blocks, emptyFilters(), ['o']),
owners: ['o'],
active: 0,
filters: emptyFilters(),
}
it('renders one group per repo', () => {
const w = mount(AttentionList, { props })
expect(w.findAll('[data-test=repo-block]')).toHaveLength(1)
})
it('bubbles filter changes up', async () => {
const w = mount(AttentionList, { props })
await w.get('[data-test=sev-crit]').trigger('click')
const [emitted] = w.emitted('update:filters')!.at(-1) as [Filters]
expect(emitted.severity).toBe('crit')
})
it('forwards clear', async () => {
const w = mount(AttentionList, { props: { ...props, active: 2 } })
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
})
it('explains an empty selection', () => {
const w = mount(AttentionList, { props: { ...props, blocks: [] } })
expect(w.get('[data-test=empty]').text()).toContain('Nothing matches')
})
})
describe('FilterBar', () => {
const blocks = buildBlocks(mixedState())
const base = (filters: Filters = emptyFilters(), owners = ['o', 'p']) => ({
facets: facetCounts(blocks, filters, owners),
owners,
active: activeFilterCount(filters),
filters,
})
it('shows live counts in every facet', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=sev-all]').text()).toBe('All 2')
expect(w.get('[data-test=sev-crit]').text()).toBe('Critical 2')
expect(w.get('[data-test=sev-warn]').text()).toBe('Warnings 0')
expect(w.get('[data-test=kind-workflow]').text()).toContain('1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
expect(w.get('[data-test=owner-o]').text()).toContain('2')
})
it('disables facet values that would match nothing, but not selected ones', () => {
const w = mount(FilterBar, { props: base() })
expect(w.get('[data-test=kind-issue]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=owner-p]').attributes('disabled')).toBeDefined()
expect(w.get('[data-test=kind-pr]').attributes('disabled')).toBeUndefined()
const sel = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['issue'] }) })
expect(sel.get('[data-test=kind-issue]').attributes('disabled')).toBeUndefined()
})
it('narrows the other facets when one is selected', () => {
const w = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['workflow'] }) })
expect(w.get('[data-test=sev-all]').text()).toBe('All 1')
expect(w.get('[data-test=kind-pr]').text()).toContain('1')
})
it('hides the owner filter when there is only one owner', () => {
const w = mount(FilterBar, { props: base(emptyFilters(), ['o']) })
expect(w.find('[data-test=owner-o]').exists()).toBe(false)
})
it('emits typed search text and clears it with Escape', async () => {
const w = mount(FilterBar, { props: base() })
const input = w.get('[data-test=search]')
await input.setValue('grpc')
expect((w.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('grpc')
const typed = mount(FilterBar, { props: base({ ...emptyFilters(), query: 'grpc' }) })
await typed.get('[data-test=search]').trigger('keydown', { key: 'Escape' })
expect((typed.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('')
})
it('offers to clear active filters with a count', async () => {
const f = { ...emptyFilters(), query: 'x', kinds: ['pr' as const] }
const w = mount(FilterBar, { props: base(f) })
expect(w.get('[data-test=clear-all]').text()).toBe('Clear 2 filters')
await w.get('[data-test=clear-all]').trigger('click')
expect(w.emitted('clear')).toHaveLength(1)
expect(mount(FilterBar, { props: base() }).find('[data-test=clear-all]').exists()).toBe(false)
})
})
describe('ProjectList', () => {
const repos = mixedState().repos
it('lists every project with release, counts and links', () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs } })
const rows = w.findAll('[data-test=project]')
expect(rows).toHaveLength(2)
expect(rows[0].text()).toContain('v1.0.0')
expect(rows[0].text()).toContain('released 3d ago')
expect(rows[0].text()).toContain('1/3 failing')
expect(rows[0].get('a[href="https://github.com/o/r/pulls"]').text()).toContain('2')
expect(rows[0].get('a[href="https://github.com/o/r/issues"]').text()).toContain('4')
expect(rows[1].get('a[href="https://github.com/o/fine/releases"]').text()).toBe('no release')
})
it('shows how many projects match and a hint when none do', () => {
const some = mount(ProjectList, {
props: { repos: repos.slice(0, 1), total: 2, focused: null, nowMs },
})
expect(some.get('[data-test=project-count]').text()).toBe('1 of 2')
const none = mount(ProjectList, { props: { repos: [], total: 2, focused: null, nowMs } })
expect(none.find('[data-test=no-projects]').exists()).toBe(true)
})
it('emits the repo when its name is chosen and highlights the focused one', async () => {
const w = mount(ProjectList, { props: { repos, total: 2, focused: 'o/fine', nowMs } })
await w.findAll('[data-test=project] button')[0].trigger('click')
expect(w.emitted('select')?.[0]).toEqual(['o/r'])
expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent')
})
})
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+37
View File
@@ -0,0 +1,37 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './Input.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: 0,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', props.class)"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'
+16
View File
@@ -0,0 +1,16 @@
<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="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
<slot />
</caption>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</td>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(),
{
colspan: 1,
},
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<TableRow>
<TableCell
:class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<th
data-slot="table-head"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-0.5',
props.class,
)
"
>
<slot />
</th>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="
cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)
"
>
<slot />
</tr>
</template>
+9
View File
@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { toggleVariants } from '@/components/ui/toggle'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '@/lib/utils'
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(
defineProps<
ToggleGroupRootProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
spacing?: number
}
>(),
{
spacing: 0,
},
)
const emits = defineEmits<ToggleGroupRootEmits>()
provide('toggleGroup', {
variant: props.variant,
size: props.size,
spacing: props.spacing,
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { VariantProps } from 'class-variance-authority'
import type { ToggleGroupItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { inject } from 'vue'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const props = defineProps<
ToggleGroupItemProps & {
class?: HTMLAttributes['class']
variant?: ToggleGroupVariants['variant']
size?: ToggleGroupVariants['size']
}
>()
const context = inject<ToggleGroupVariants>('toggleGroup')
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ToggleVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Toggle, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { toggleVariants } from '.'
const props = withDefaults(
defineProps<
ToggleProps & {
class?: HTMLAttributes['class']
variant?: ToggleVariants['variant']
size?: ToggleVariants['size']
}
>(),
{
variant: 'default',
size: 'default',
disabled: false,
},
)
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>
+28
View File
@@ -0,0 +1,28 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Toggle } from './Toggle.vue'
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-slot="slotProps" data-slot="tooltip" v-bind="forwarded">
<slot v-bind="slotProps" />
</TooltipRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
props.class,
)
"
>
<slot />
<TooltipArrow
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs"
/>
</TooltipContent>
</TooltipPortal>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger data-slot="tooltip-trigger" v-bind="props">
<slot />
</TooltipTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16
View File
@@ -0,0 +1,16 @@
import type { TrackerState } from './types'
export async function fetchState(signal?: AbortSignal): Promise<TrackerState> {
const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } })
if (!res.ok) throw new Error(`state request failed: HTTP ${res.status}`)
return (await res.json()) as TrackerState
}
/** Thrown when the server refuses a rescan because one ran recently. */
export class RescanLimitedError extends Error {}
export async function requestRefresh(): Promise<void> {
const res = await fetch('/refresh', { method: 'POST' })
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}`)
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import {
activeFilterCount,
applyFilters,
emptyFilters,
facetCounts,
filtersFromSearch,
filtersToSearch,
kindGroup,
matchesQuery,
repoMatches,
type Filters,
} from './filters'
import { buildBlocks } from '@/stores/tracker'
import { alert, repo, state } from '@/test/fixtures'
const fleet = () =>
buildBlocks(
state({
repos: [
repo({ fullName: 'a/one', health: 'crit' }),
repo({ fullName: 'b/two', health: 'warn', release: null }),
],
alerts: [
alert({
repo: 'a/one',
kind: 'workflow_failed',
severity: 'crit',
subject: 'CI',
url: '1',
}),
alert({
repo: 'a/one',
kind: 'pr_waiting',
severity: 'warn',
subject: '#3 Bump grpc',
url: '2',
}),
alert({
repo: 'b/two',
kind: 'issues_stale',
severity: 'warn',
subject: '4 stale issues',
url: '3',
}),
alert({
repo: 'b/two',
kind: 'partial_data',
severity: 'warn',
subject: 'Incomplete data',
url: '4',
}),
],
}),
)
const withFilters = (over: Partial<Filters>): Filters => ({ ...emptyFilters(), ...over })
describe('kindGroup', () => {
it.each([
['workflow_failed', 'workflow'],
['workflow_stuck', 'workflow'],
['pr_waiting', 'pr'],
['pr_checks_failing', 'pr'],
['issues_stale', 'issue'],
['refresh_failed', 'data'],
['data_stale', 'data'],
['partial_data', 'data'],
['something_new', 'data'],
])('%s -> %s', (kind, group) => expect(kindGroup(kind)).toBe(group))
})
describe('matchesQuery', () => {
it('requires every term, ignores case and accepts an empty query', () => {
expect(matchesQuery('', 'anything')).toBe(true)
expect(matchesQuery(' ', 'anything')).toBe(true)
expect(matchesQuery('bump GRPC', 'o/r', '#3 Bump google.golang.org/grpc')).toBe(true)
expect(matchesQuery('bump nothing', '#3 Bump grpc')).toBe(false)
})
})
describe('applyFilters', () => {
it('returns everything for empty filters', () => {
expect(applyFilters(fleet(), emptyFilters()).flatMap((b) => b.rows)).toHaveLength(4)
})
it('combines severity, kind, owner and search', () => {
const rows = (f: Filters) =>
applyFilters(fleet(), f).flatMap((b) => b.rows.map((r) => r.subject))
expect(rows(withFilters({ severity: 'crit' }))).toEqual(['CI'])
expect(rows(withFilters({ kinds: ['pr', 'issue'] }))).toEqual([
'#3 Bump grpc',
'4 stale issues',
])
expect(rows(withFilters({ owners: ['b'] }))).toEqual(['4 stale issues', 'Incomplete data'])
expect(rows(withFilters({ query: 'grpc' }))).toEqual(['#3 Bump grpc'])
expect(rows(withFilters({ query: 'a/one', kinds: ['pr'] }))).toEqual(['#3 Bump grpc'])
expect(rows(withFilters({ repo: 'b/two', severity: 'crit' }))).toEqual([])
})
it('drops blocks left without rows', () => {
expect(
applyFilters(fleet(), withFilters({ kinds: ['workflow'] })).map((b) => b.repo.fullName),
).toEqual(['a/one'])
})
it('also searches the release tag', () => {
expect(
applyFilters(fleet(), withFilters({ query: 'v1.0.0' })).map((b) => b.repo.fullName),
).toEqual(['a/one'])
})
})
describe('facetCounts', () => {
it('counts each facet with the others applied, but not itself', () => {
const f = withFilters({ kinds: ['pr'] })
const c = facetCounts(fleet(), f, ['a', 'b'])
expect(c.kinds).toEqual({ workflow: 1, pr: 1, issue: 1, data: 1 })
expect(c.severity).toEqual({ all: 1, crit: 0, warn: 1 })
expect(c.owners).toEqual({ a: 1, b: 0 })
})
it('counts everything for empty filters', () => {
const c = facetCounts(fleet(), emptyFilters(), ['a', 'b'])
expect(c.severity).toEqual({ all: 4, crit: 1, warn: 3 })
expect(c.owners).toEqual({ a: 2, b: 2 })
})
})
describe('repoMatches', () => {
it('uses the query and owner filters only', () => {
const r = repo({ fullName: 'a/one', description: 'Kubernetes mirror' })
expect(repoMatches(r, withFilters({ query: 'mirror' }))).toBe(true)
expect(repoMatches(r, withFilters({ query: 'nope' }))).toBe(false)
expect(repoMatches(r, withFilters({ owners: ['b'] }))).toBe(false)
expect(repoMatches(r, withFilters({ severity: 'crit', kinds: ['pr'] }))).toBe(true)
})
})
describe('activeFilterCount', () => {
it('counts each active filter value', () => {
expect(activeFilterCount(emptyFilters())).toBe(0)
expect(
activeFilterCount(
withFilters({
query: ' x ',
severity: 'warn',
kinds: ['pr', 'issue'],
owners: ['a'],
repo: 'a/b',
}),
),
).toBe(6)
expect(activeFilterCount(withFilters({ query: ' ' }))).toBe(0)
})
})
describe('URL round trip', () => {
it('omits defaults', () => {
expect(filtersToSearch(emptyFilters())).toBe('')
})
it('serialises and restores every filter', () => {
const f = withFilters({
query: 'bump grpc',
severity: 'crit',
kinds: ['pr', 'data'],
owners: ['a', 'b'],
repo: 'a/one',
})
expect(filtersFromSearch(filtersToSearch(f))).toEqual(f)
})
it('ignores unknown values from a hand-edited URL', () => {
expect(filtersFromSearch('?sev=nope&kind=pr,bogus&owner=,a')).toEqual(
withFilters({ kinds: ['pr'], owners: ['a'] }),
)
})
})
+129
View File
@@ -0,0 +1,129 @@
import type { Block, Repo, Row } from './types'
export type SeverityFilter = 'all' | 'crit' | 'warn'
export type KindGroup = 'workflow' | 'pr' | 'issue' | 'data'
export interface Filters {
query: string
severity: SeverityFilter
kinds: KindGroup[]
owners: string[]
repo: string | null
}
export const KIND_GROUPS: KindGroup[] = ['workflow', 'pr', 'issue', 'data']
export const emptyFilters = (): Filters => ({
query: '',
severity: 'all',
kinds: [],
owners: [],
repo: null,
})
export function kindGroup(kind: string): KindGroup {
if (kind.startsWith('workflow_')) return 'workflow'
if (kind.startsWith('pr_')) return 'pr'
if (kind === 'issues_stale') return 'issue'
return 'data'
}
export const ownerOf = (fullName: string): string => fullName.split('/')[0]
/** Every whitespace-separated term must appear somewhere in the haystack. */
export function matchesQuery(query: string, ...fields: string[]): boolean {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return true
const haystack = fields.join(' ').toLowerCase()
return terms.every((t) => haystack.includes(t))
}
/** Facets a row or block can be counted under; the named one is ignored while counting it. */
export type Facet = 'severity' | 'kinds' | 'owners'
function rowMatches(repo: Repo, row: Row, f: Filters, skip?: Facet): boolean {
if (f.repo && repo.fullName !== f.repo) return false
if (skip !== 'severity' && f.severity !== 'all' && row.severity !== f.severity) return false
if (skip !== 'kinds' && f.kinds.length > 0 && !f.kinds.includes(kindGroup(row.kind))) return false
if (skip !== 'owners' && f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName)))
return false
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', row.subject, row.detail)
}
export function applyFilters(blocks: Block[], f: Filters, skip?: Facet): Block[] {
const out: Block[] = []
for (const b of blocks) {
const rows = b.rows.filter((r) => rowMatches(b.repo, r, f, skip))
if (rows.length > 0) out.push({ repo: b.repo, rows })
}
return out
}
export interface Facets {
severity: Record<SeverityFilter, number>
kinds: Record<KindGroup, number>
owners: Record<string, number>
}
/** Counts rows per facet value, each with every other filter applied (faceted search). */
export function facetCounts(blocks: Block[], f: Filters, owners: string[]): Facets {
const rows = (skip: Facet) =>
applyFilters(blocks, f, skip).flatMap((b) => b.rows.map((r) => ({ b, r })))
const sev = rows('severity')
const kinds = rows('kinds')
const own = rows('owners')
return {
severity: {
all: sev.length,
crit: sev.filter(({ r }) => r.severity === 'crit').length,
warn: sev.filter(({ r }) => r.severity === 'warn').length,
},
kinds: Object.fromEntries(
KIND_GROUPS.map((g) => [g, kinds.filter(({ r }) => kindGroup(r.kind) === g).length]),
) as Facets['kinds'],
owners: Object.fromEntries(
owners.map((o) => [o, own.filter(({ b }) => ownerOf(b.repo.fullName) === o).length]),
),
}
}
/** Repos that pass the query and owner filters; used for the project list and the strip. */
export function repoMatches(repo: Repo, f: Filters): boolean {
if (f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName))) return false
return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', repo.description)
}
export function activeFilterCount(f: Filters): number {
return (
(f.query.trim() ? 1 : 0) +
(f.severity !== 'all' ? 1 : 0) +
f.kinds.length +
f.owners.length +
(f.repo ? 1 : 0)
)
}
/** Serialises filters for the URL; defaults are omitted so the plain URL stays clean. */
export function filtersToSearch(f: Filters): string {
const p = new URLSearchParams()
if (f.query.trim()) p.set('q', f.query.trim())
if (f.severity !== 'all') p.set('sev', f.severity)
if (f.kinds.length) p.set('kind', f.kinds.join(','))
if (f.owners.length) p.set('owner', f.owners.join(','))
if (f.repo) p.set('repo', f.repo)
const s = p.toString()
return s ? `?${s}` : ''
}
export function filtersFromSearch(search: string): Filters {
const p = new URLSearchParams(search)
const sev = p.get('sev')
const list = (key: string) => (p.get(key) ?? '').split(',').filter(Boolean)
return {
query: p.get('q') ?? '',
severity: sev === 'crit' || sev === 'warn' ? sev : 'all',
kinds: list('kind').filter((k): k is KindGroup => (KIND_GROUPS as string[]).includes(k)),
owners: list('owner'),
repo: p.get('repo'),
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { ageLabel, ageSince, isZeroTime, secondsSince, shorten } from './format'
describe('ageLabel', () => {
it.each([
[-60, '0m'],
[300, '5m'],
[5400, '1h'],
[47 * 3600, '47h'],
[49 * 3600, '2d'],
[10 * 86400, '10d'],
])('%is -> %s', (seconds, want) => {
expect(ageLabel(seconds)).toBe(want)
})
})
describe('time helpers', () => {
const now = Date.parse('2026-10-02T12:00:00Z')
it('measures seconds since a timestamp', () => {
expect(secondsSince('2026-10-02T11:00:00Z', now)).toBe(3600)
})
it('returns an empty age for unparsable input', () => {
expect(ageSince('not a date', now)).toBe('')
expect(ageSince('2026-10-02T09:00:00Z', now)).toBe('3h')
})
it('treats the Go zero time as unknown', () => {
expect(isZeroTime('0001-01-01T00:00:00Z')).toBe(true)
expect(isZeroTime('')).toBe(true)
expect(isZeroTime('2026-10-02T12:00:00Z')).toBe(false)
})
})
describe('shorten', () => {
it('keeps short text and cuts long text by characters, not bytes', () => {
expect(shorten('ok')).toBe('ok')
const long = 'é'.repeat(200)
const out = shorten(long)
expect(Array.from(out)).toHaveLength(90)
expect(out.endsWith('…')).toBe(true)
})
})
+29
View File
@@ -0,0 +1,29 @@
const HOUR = 3600
const DAY = 24 * HOUR
/** Compact age such as "3d", "5h" or "12m"; mirrors the Go collector.Age helper. */
export function ageLabel(seconds: number): string {
if (seconds < 0) return '0m'
if (seconds >= 2 * DAY) return `${Math.floor(seconds / DAY)}d`
if (seconds >= HOUR) return `${Math.floor(seconds / HOUR)}h`
return `${Math.floor(seconds / 60)}m`
}
/** Seconds between an RFC 3339 timestamp and nowMs. */
export function secondsSince(iso: string, nowMs: number): number {
return Math.floor((nowMs - Date.parse(iso)) / 1000)
}
export function ageSince(iso: string, nowMs: number): string {
return Number.isNaN(Date.parse(iso)) ? '' : ageLabel(secondsSince(iso, nowMs))
}
/** Go encodes the zero time as year 1; treat it as "unknown". */
export function isZeroTime(iso: string): boolean {
return !iso || iso.startsWith('0001-')
}
export function shorten(text: string, max = 90): string {
const chars = Array.from(text)
return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…'
}
+27
View File
@@ -0,0 +1,27 @@
import {
faClockRotateLeft,
faCodePullRequest,
faCircleDot,
faGears,
faPlugCircleXmark,
faTriangleExclamation,
type IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
/** Maps a server alert kind to the icon and group used in the UI. */
export const KIND_ICON: Record<string, IconDefinition> = {
workflow_failed: faGears,
workflow_stuck: faGears,
pr_waiting: faCodePullRequest,
pr_checks_failing: faCodePullRequest,
issues_stale: faCircleDot,
refresh_failed: faPlugCircleXmark,
data_stale: faClockRotateLeft,
partial_data: faTriangleExclamation,
}
export function iconFor(kind: string): IconDefinition {
return KIND_ICON[kind] ?? faTriangleExclamation
}
export const DATA_KINDS = ['refresh_failed', 'data_stale', 'partial_data']
+71
View File
@@ -0,0 +1,71 @@
export type Severity = 'ok' | 'warn' | 'crit'
export interface Release {
tag: string
url: string
publishedAt: string
prerelease: boolean
fromTag: boolean
}
export interface Repo {
fullName: string
url: string
releasesUrl: string
description: string
health: Severity
error: string
workflows: { total: number; failing: number; running: number }
prs: { open: number; waiting: number; failingChecks: number; oldestSeconds: number }
issues: { open: number; stale: number }
archived: boolean
up: boolean
refreshedAt: string
release: Release | null
}
export interface Alert {
repo: string
kind: string
severity: Severity
subject: string
detail: string
url: string
since: string
}
export interface Thresholds {
prWarnSeconds: number
prCritSeconds: number
issueStaleSeconds: number
workflowStuckSeconds: number
workflowDormantSeconds: number
}
export interface TrackerState {
now: string
lastRefresh: string
intervalSeconds: number
loaded: boolean
refreshing: boolean
manualRefresh: boolean
counts: { crit: number; warn: number }
thresholds: Thresholds
repos: Repo[]
alerts: Alert[]
}
/** One line in a repo block: every alert that targets the same URL, merged. */
export interface Row {
kind: string
subject: string
detail: string
url: string
since: string
severity: Exclude<Severity, 'ok'>
}
export interface Block {
repo: Repo
rows: Row[]
}

Some files were not shown because too many files have changed in this diff Show More