README
¶
github-scout
One cross-repo view of everything that needs a look across your GitHub repos: open pull requests, open issues, code-scanning alerts, and failed Actions runs, shipped to Loki and rendered by a ready-made Grafana dashboard, with a click-through link on every row.
The problem
If you have more than a handful of repositories, "is anything waiting on me: a stale PR, an open issue, a security alert, a broken nightly job?" is a surprisingly hard question to answer:
- The Grafana GitHub datasource plugin can list workflow runs for exactly one repository and one workflow file per query. There is no "all workflows, all repos" mode.
- GitHub's org-level endpoints don't help a personal account, and private repos have no cross-repo feed at all.
- The GitHub UI shows you each of these one repo at a time, and email notifications are easy to tune out.
So a broken nightly job (or an open code-scanning alert) in a repo you haven't opened in a week goes unnoticed. github-scout closes that gap with a single pane of glass across every repo you own.
What it does
github-scout polls every repository it can see for a configured owner on a schedule and surfaces four actionable signals across all of them, each as a structured JSON log line. Ship those lines to Loki with Grafana Alloy (or any log collector) and the bundled dashboard gives you:
- Open pull requests: every open PR across every repo, newest first, with a click-through link (Renovate PRs filtered out by default);
- Open issues: every open issue, with labels and author (Renovate and auto-generated trackers filtered out by default);
- Code-scanning alerts: every open CodeQL / code-scanning alert, colour- coded by severity;
- Failed Actions runs: every failed, timed-out, or startup-failed run across all repos, newest first, with a click-through link;
- a scout-health tile so you know the watcher itself is still scanning.
It discovers repositories and workflows dynamically on every scan, so a new repo (or a new workflow inside an existing one) is picked up automatically with zero configuration changes.
Design
Logs, not metrics
A workflow run is an event carrying rich, high-cardinality detail: a unique run ID and URL, a workflow name, a branch, a trigger. An open PR, issue, or alert is likewise an item with a title, author, and link. That is log-shaped data, not a numeric time-series. Modelling it as a Prometheus metric forces a bad trade-off:
- a bare counter (
open_prs_total 7) tells you how many but nothing you can click, which loses the entire actionable payload; or - an info-metric with the URL/title as labels reintroduces the detail but abuses Prometheus with unbounded label cardinality and sticky stale series.
So github-scout writes structured logs instead. The dashboard still shows a
count (via a LogQL count), and every row keeps its repo, title, and link.
The guiding principle: surface actionable items, not stats for stats' sake.
Two emission models
The four signals split into two shapes:
- Event-once (Actions runs). A completed run happens at a point in time, so
each run ID is emitted exactly once, as
msg="workflow run"carrying itsconclusion. A plain log count therefore equals the number of distinct runs; the dashboard filters by conclusion for the failures view. The dedup set is a map of run ID → creation time, pruned to the lookback window so it stays bounded. It lives in memory in the long-lived scheduled process and is also persisted to a small file (/tmp/seen-runs.json) so the same run is not re-emitted across one-shottriggerruns; see State below. - Snapshot (open PRs, open issues, code-scanning alerts). These are current state: an item stays open across scans, so github-scout re-emits the full current set every scan. When an item is closed / merged / fixed it simply stops appearing in later snapshots, and the dashboard reads the most recent scan as "what is open right now" (panels deduplicate by repo + number over a window slightly longer than the poll interval). No dedup state is needed.
State
github-scout keeps no database; history lives in Loki. The only cross-scan
state is the event-once dedup set (run ID → creation time, bounded to the
lookback window). It lives in memory during a run and is persisted (as JSON,
through a flock'd single-slot read-modify-write transaction,
scheduler.SlotFile) to a small file at /tmp/seen-runs.json at the end of
each scan, then reloaded at process start, so a plain restart re-emits nothing.
Each save merges the on-disk set with the in-memory one under the lock, so
concurrent writers (the scheduled daemon racing an externally triggered scan,
or two overlapping triggers) never lose each other's entries to a
last-writer-wins overwrite. The scheduled daemon persists after every scan;
under an external scheduler each trigger is a fresh process that reloads the
previous one's set from the same file (/tmp is shared across docker exec
triggers). In resident-idle mode the
daemon itself never scans, so only its trigger execs persist. A cold start (the first run, or a container recreate
that clears /tmp) at worst re-logs runs still inside the lookback window; the
dashboard also dedups run counts by run ID, so counts stay correct either way.
Persistence is a best-effort optimisation, never a correctness dependency.
Architecture
main.go composition root + jittered poll loop
└─ internal/config env-var parsing and validation
└─ internal/github GitHub REST client (repos, runs, PRs, issues, code scanning)
└─ internal/collect scan orchestrator: discover → collect signals → emit
└─ apiClient (interface) consumer-side seam; the github client satisfies it
└─ internal/model pure data types (Repo, WorkflowRun, PullRequest, Issue, CodeScanningAlert)
└─ internal/urlsafe URL path-segment safety predicate
Data flows in one direction each scan: config parameterises a collect.Collector,
which asks the github.Client to discover the owner's repos, then collects open
PRs and issues with one cross-repo Search query each and walks the repos for
failed runs and code-scanning alerts. New failures are deduplicated by run ID;
the snapshot signals are emitted in full. Everything goes out as slog JSON to
stdout with UTC timestamps (zone-stable regardless of the container's TZ).
Alloy ships stdout to Loki; Grafana queries Loki. There is no HTTP
server and no listening port.
The collect package depends on a small consumer-side apiClient interface
rather than the concrete client, so the orchestration logic is unit-tested with
a scripted fake and the HTTP client is tested separately against an httptest
server.
Quick start
services:
github-scout:
image: ghcr.io/cplieger/github-scout:latest
container_name: github-scout
restart: unless-stopped
environment:
GITHUB_OWNER: "your-login" # user or org whose repos to scan
GITHUB_TOKEN: "ghp_xxx" # see token scopes below
SCAN_INTERVAL: "15m" # Go duration between scans; "off" = resident-idle
LOOKBACK_HOURS: "72" # how far back to consider failed runs
EXCLUDE_REPOS: "" # comma-separated bare repo names to skip (all signals)
CODE_SCANNING_EXCLUDE_REPOS: "" # skip code scanning only (e.g. private repos without GHAS)
LOG_LEVEL: "info"
# Optional noise filters (defaults shown), raw GitHub search qualifiers:
# PR_EXCLUDE_QUERY: "-author:app/renovate"
# ISSUE_EXCLUDE_QUERY: "-author:app/renovate -label:renovate -label:auto-generated"
The image is published to ghcr.io/cplieger/github-scout. Pin a digest in
production.
Token scopes
github-scout reads four signals, so the token needs read access to repository metadata, Actions, pull requests, issues, and code scanning. Either token type works, and both keep discovery dynamic (new repos auto-included):
- Classic PAT:
repocovers private and public repos for all four signals, orpublic_repofor public-only repositories.workflowandsecurity_eventsare not separate requirements (repoalready grants Actions and code-scanning read). - Fine-grained PAT (recommended): Repository access = All repositories (so repos you create later are discovered automatically); Repository permissions, all Read-only: Actions, Pull requests, Issues, Code scanning alerts. Metadata: Read is added automatically and powers the repo listing. Avoid "Only select repositories": it freezes the set, so new repos silently stop being scanned.
github-scout degrades gracefully if a permission is missing: a repo without code
scanning (or a token lacking that permission) simply yields no alerts rather than
failing the scan. A private repo on a plan without GitHub Advanced Security always
returns 403 on code scanning; list it in CODE_SCANNING_EXCLUDE_REPOS to skip
just that signal (its runs, PRs, and issues keep scanning). The token is only ever
sent to api.github.com as a Bearer header and is never logged (only its presence
is logged at startup).
Configuration reference
| Variable | Description | Default | Required |
|---|---|---|---|
GITHUB_OWNER |
GitHub login (user or org) whose repositories are scanned | `` | Yes |
GITHUB_TOKEN |
Personal access token (see scopes above) | `` | Yes |
SCAN_INTERVAL |
Gap between scans, a Go duration (15m, 1h). off = resident-idle |
15m |
No |
LOOKBACK_HOURS |
How far back each scan considers failed runs (also bounds the dedup set) | 72 |
No |
EXCLUDE_REPOS |
Comma-separated bare repo names to skip (silences all signals) | `` | No |
CODE_SCANNING_EXCLUDE_REPOS |
Comma-separated bare repo names to skip for code scanning only (others kept) | `` | No |
LOG_LEVEL |
debug, info, warn, error |
info |
No |
Out-of-range or unparseable values fall back to the default (a bad
SCAN_INTERVAL keeps scanning at 15m; an out-of-range LOOKBACK_HOURS is
clamped), so misconfiguration degrades safely rather than crashing.
Run modes
You choose an internal timer or an external scheduler:
- Scheduled (
SCAN_INTERVAL=15m, the default): an internal jittered timer drives the scans. Failed runs are deduplicated in memory and emitted once. - Resident-idle (
SCAN_INTERVAL=off): no internal timer. The container sits healthy and idle while an external scheduler runsgithub-scout triggeron its own cadence, e.g. an Ofeliajob-exec. - Trigger (
github-scout trigger): one scan, then exit 0/1; the target for that external scheduler, or a manual one-shot run.
Each trigger is an independent process, but the run dedup set persists across
triggers so each completed run is emitted exactly once (see State above for the
/tmp/seen-runs.json details and the container-recreate caveat). The snapshot
signals (PRs / issues / alerts) re-emit the full open set every scan by design.
Two optional noise filters take raw GitHub search qualifiers, appended to the cross-repo PR/issue searches:
PR_EXCLUDE_QUERY: default-author:app/renovate(drops Renovate PRs).ISSUE_EXCLUDE_QUERY: default-author:app/renovate -label:renovate -label:auto-generated(drops Renovate and auto-generated trackers).
Output
github-scout writes JSON to stdout, one line per item. A failed run looks like:
{
"time": "2026-06-21T12:00:03Z",
"level": "INFO",
"msg": "workflow run",
"repo": "owner/example",
"workflow": "CI",
"conclusion": "failure",
"branch": "main",
"event": "push",
"run_number": 1060,
"run_id": 12345678,
"url": "https://github.com/owner/example/actions/runs/12345678",
"created_at": "2026-06-19T08:07:35Z"
}
Each signal has a stable msg the dashboard and any Loki ruler alert filter on.
Every line also carries repo, url, and created_at:
workflow run(event-once):workflow,conclusion,branch,event,run_number,run_idopen pull request(snapshot):number,title,author,draftopen issue(snapshot):number,title,author,labelscode scanning alert(snapshot):number,rule,severity,tool
The conclusion is any completed-run outcome (success, failure,
timed_out, startup_failure, cancelled, skipped, or neutral); the
dashboard treats failure / timed_out / startup_failure as the failure set
(the failed-run count tile and the failures table). Each scan also logs a
scan complete summary line (scanned, skipped, open_prs, open_issues,
code_alerts, new_runs, new_failures, tracked, duration), plus three
data-integrity fields: errors (how many signal collections failed this scan),
degraded (true when errors > 0, or when discovery returned zero repos so
nothing was scanned), and failed_signals (the comma-joined signals it could
not read, e.g. code_scanning). These distinguish a verified 0 ("checked,
nothing there") from an unverified 0 ("could not check") — which matters most
for the code-scanning security signal.
A repo-discovery failure logs at error level and is the only failure that
marks the container unhealthy: health is a liveness signal a restart could
clear. Per-signal collection failures do NOT flip health (a restart cannot fix
a missing token scope or a rate limit); instead they are reported as data
integrity. An incidental per-repo failure — a transient error, or one private
repo without GitHub Advanced Security returning 403 on code scanning — is
counted as degraded (it reddens the dashboard tile) but is NOT paged; to stop
an always-403 private repo from reddening every scan, list it in
CODE_SCANNING_EXCLUDE_REPOS, which skips just its code-scanning read while
keeping its other signals. A SYSTEMIC failure escalates to a distinct
error-level scan degraded line carrying a machine cause, a human
reason, and failed_signals, so an alert
fires on a scan that went blind instead of the failure hiding behind a quiet
all-zero scan. The causes cover a rejected token, a rate limit, a token that can
no longer see any repos, and a signal that could not be read across every repo
that has it. See CONTRIBUTING.md for
the full cause enum and the exact escalation rules.
Grafana integration
Ship the container's stdout to Loki (Grafana Alloy's Docker log discovery does
this with no extra configuration) and import grafana-dashboard.json (or drop
it into a file-based dashboard provider). The dashboard uses a standard Loki
datasource (no plugins) and is organised top to bottom in the order you ask
questions:
- At a glance: four count tiles (open PRs, open issues, code-scanning alerts, and failed CI runs in the picker range).
- Open work: linked tables of the open PRs, issues, and code-scanning alerts as of the most recent scan. The Created column shows relative age ("3 days ago") with a red-to-green gradient, so the stalest items stand out.
- Recent CI failures: a linked table of failed, timed-out, and startup-failed runs in the selected time range (successful runs are omitted).
- Scout health: a STALLED tile (red when no scan completed recently)
alongside a Scan Integrity tile that is neutral while recent scans read
every signal and turns red when one was degraded — a signal it could not
read, so a
0above may be unverified rather than confirmed empty — and a Recent scan problems panel listing every warning and error behind it.
Two controls shape what you see:
- The Snapshot window variable sets how far back the open-work tables and
their tiles read for the latest snapshot. It must be at least
SCAN_INTERVAL; the default30mgives 2x headroom at the default 15m scan interval. It does not affect the failure panels. - The time picker affects only the failed-runs tile and table; keep it
within
LOOKBACK_HOURS(default 72h), the furthest back each scan looks.
Every panel is built on a single Loki selector, for example:
{container="github-scout"} | json | msg=`open pull request`
Tables render the url field as a click-through link.
Alerting
github-scout has no metrics endpoint; its operational state is in its JSON logs. Ship the container's logs to Loki as above and evaluate these two rules with Loki's ruler; firing alerts deliver through your Alertmanager exactly like Prometheus metric alerts. The first catches a scan that ran but went blind (a rejected token, a rate limit, or a signal dark across every repo); the second is a deadman that fires when no scan completes at all.
groups:
- name: github-scout
rules:
- alert: GithubScoutScanDegraded
expr: |
sum(count_over_time({container="github-scout"} |= `scan degraded` | json | msg=`scan degraded` [40m])) >= 2
for: 0m
labels:
severity: warning
annotations:
summary: "github-scout scans degraded (signal counts unverified)"
description: >
github-scout logged repeated degraded scans in the last 40m: a
signal could not be read, so the dashboard counts (especially Code
Scanning Alerts) may read 0 because it could not check, not because
nothing is there. The `scan degraded` log line carries the cause
(token_invalid / rate_limited / no_repos_visible /
code_scanning_blind / runs_blind / signal_blind) and the
failed_signals field.
- alert: GithubScoutScanStalled
expr: |
absent_over_time({container="github-scout"} |= `scan complete` [40m])
for: 0m
labels:
severity: warning
annotations:
summary: "github-scout has not completed a scan in 40m"
description: >
No "scan complete" line from github-scout in 40m (it scans every
~15m by default). The scanner is wedged, the container is down, or
the token was revoked at repo discovery, so every dashboard panel
goes stale and silently reads empty. The Scan Integrity tile cannot
flag this (no scan ran), so this liveness check does. Check the
container and the GITHUB_TOKEN.
Thresholds and the severity label are starting points. Both windows assume
the default SCAN_INTERVAL=15m (40m is roughly 2.5 scan intervals), so widen
them if you lengthen SCAN_INTERVAL; adjust the container selector (or job
/ service, depending on your log collector) to your deployment, and route by
whatever labels your Alertmanager uses.
These rules assume built-in scheduling (SCAN_INTERVAL set to a duration),
where the scan runs as PID 1 and its logs reach your collector. Under
SCAN_INTERVAL=off each github-scout trigger is a docker exec child whose
output goes to the trigger, not the container's log stream, so neither rule
fires; alert on your external scheduler's own job result instead.
Healthcheck
A marker file at /tmp/.healthy is written after each scan whose repo discovery
succeeded, and cleared otherwise. The health subcommand
(/github-scout health) checks the marker and exits non-zero when unhealthy;
this is the container's HEALTHCHECK, so no HTTP port or shell is needed on the
distroless image. In scheduled mode the container starts healthy on boot; the
first scan runs in the background (as the scheduler loop's first iteration), so a
slow first scan on a large account cannot hold the container unhealthy past the
HEALTHCHECK start-period, and the marker thereafter reflects each completed
scan's repo-discovery outcome. In resident-idle mode
(SCAN_INTERVAL=off) it reports healthy as soon as it is up and idle
(liveness); each external trigger exec then updates the marker to reflect that
scan's outcome. Per-repo run-list failures are tolerated (logged; the
scan stays healthy); only a repo-discovery failure (bad token, rate limit) marks
the container unhealthy.
Security
- Distroless, rootless, no shell. Runs as
nonrootongcr.io/distroless/staticwith no package manager or shell to exploit. - No listening port. There is no HTTP server; nothing to reach from the network. Output is stdout; health is a file marker.
- Minimal writable state. The only filesystem writes are the
/tmp/.healthymarker and a small/tmp/seen-runs.jsondedup file; no database, no persistent volume. Under the hardened profile below, both live on anoexec,nosuid,nodevtmpfs. - Minimal supply chain. No non-
cpliegerruntime dependencies; thecpliegerhttpxandhealthlibraries provide retry/backoff and the health probe. Response bodies are capped withio.LimitReader; URL path segments built from input are validated to reject traversal and injection characters. - Secret hygiene. The token is sent only to
api.github.comand is never written to logs.
Hardened deployment
To lock the container down further, layer these directives onto the Quick start service:
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- "/tmp:size=16m,mode=1777,noexec,nosuid,nodev"
read_only: true makes the root filesystem read-only, so the file-marker
health probe needs a writable /tmp; the tmpfs supplies it. size=16m
covers both the /tmp/.healthy marker and the /tmp/seen-runs.json
run-dedup state. Without read_only, /tmp is writable on the container
layer and no tmpfs is needed.
Limitations
- Dependabot alerts are out of scope. The four signals github-scout surfaces (open PRs, open issues, code-scanning alerts, failed Actions runs) are the cross-repo views with no usable aggregation elsewhere. Dependabot has its own alerting and is intentionally left out; the collector is structured so more signal types can be added later (see CONTRIBUTING.md).
- github.com only. GitHub Enterprise Server would require making the API base URL configurable.
- Re-emission on container recreate. A recreate (not a plain restart) clears
the
/tmpdedup file, so the next scan re-logs runs still inside the lookback window once (see State above).
Development
Requires Go (see go.mod for the pinned version). From a clone:
go build ./... # compile
go test ./... # unit tests
go test -race ./... # race detector
go test ./internal/github -run=x -fuzz=FuzzDecodeRunsPage -fuzztime=30s # fuzz the API decode
golangci-lint run ./... # lint (config synced from cplieger/ci)
To run it locally against your account, export a token and use the trigger
subcommand (one scan, then exit):
GITHUB_TOKEN=ghp_xxx GITHUB_OWNER=your-login LOG_LEVEL=debug go run . trigger
See CONTRIBUTING.md for the architecture map, the extension point for new signal types, and the contribution workflow.
Dependencies
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Source |
|---|---|
| golang | Go |
| Distroless static | Distroless |
| cplieger/httpx | httpx, retry/backoff client |
| cplieger/health | health, file-marker probe |
| cplieger/scheduler | scheduler, poll loop, slot-file state |
| pgregory.net/rapid | rapid, tests only |
Credits
An original tool building on the GitHub REST API. The API-client design (auth headers, the API-version pin, page-count pagination) follows patterns from the MIT-licensed githubexporter/github-exporter and xrstf/github_exporter. No code was copied verbatim; see NOTICE for attribution.
Contributing
Issues and pull requests are welcome. github-scout is deliberately small and single-purpose, so please open an issue before starting anything larger than a bug fix. See CONTRIBUTING.md for the architecture map, local setup, testing conventions, and the step-by-step extension point for adding new signal types.
Disclaimer
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
Documentation
¶
Overview ¶
Package main implements github-scout, which scans all of a GitHub owner's repositories on a schedule and emits the four actionable signals — failed Actions runs, open pull requests, open issues, and code-scanning alerts — as structured log lines for Loki. It is the single source for a cross-repo GitHub dashboard, replacing the Grafana GitHub-datasource plugin (which cannot enumerate "all workflows across all repos" and has no cross-repo view).
main.go is a pure composition root: it wires config -> *http.Client -> github.Client -> collect.Collector -> health.Marker. All logic lives in internal/*; this file holds no business rules.
Three run modes (matching the shared scheduled-app convention):
- scheduled (SCAN_INTERVAL > 0): an internal jittered timer.
- resident-idle (SCAN_INTERVAL = off): no internal timer; sits healthy and idle, awaiting external `github-scout trigger` execs (Ofelia).
- trigger (`github-scout trigger`): one one-shot scan, then exit 0/1 — the target for an Ofelia job-exec or cron.
Output model is slog-to-stdout, not a /metrics endpoint: these signals are high-cardinality events/records (run IDs, PR/issue numbers, URLs), not numeric time-series, so they belong in Loki. There is no HTTP server and no listening port; health is a file marker checked by the `health` subcommand.
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
collect
Package collect is github-scout's scan orchestrator.
|
Package collect is github-scout's scan orchestrator. |
|
config
Package config parses github-scout configuration from environment variables.
|
Package config parses github-scout configuration from environment variables. |
|
github
Package github is github-scout's GitHub REST API client.
|
Package github is github-scout's GitHub REST API client. |
|
model
Package model holds the pure data types describing the GitHub signals github-scout surfaces.
|
Package model holds the pure data types describing the GitHub signals github-scout surfaces. |
|
urlsafe
Package urlsafe holds URL path segment safety predicates.
|
Package urlsafe holds URL path segment safety predicates. |