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 (with a failure-rate trend) — shipped to Loki, 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;
- Actions runs — every completed run; the failed ones (failure, timed out, or startup failure) are the headline, with a failure-rate trend over time;
- a scout-health row (status, scan errors, repos scanned) so you know the watcher itself is alive.
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 — it 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 per process lifetime, 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 and derives the failure rate from the same stream. The dedup set is an in-memory map of run ID → creation time, pruned to the lookback window so memory stays bounded. - 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.
github-scout is stateless — there is no database and no on-disk state. The only cross-scan state is the event-once dedup set, which lives in memory; history lives in Loki. A consequence: a process restart can re-log runs still inside the lookback window. The dashboard's count tiles deduplicate by run ID so counts stay correct. Given the runs inside the window are bounded and restarts are rare, this is a deliberate simplicity-for-robustness trade.
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, FailedRun, 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. 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
user: "1000:1000"
environment:
TZ: "Europe/Paris"
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
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"
# Writable home for the /tmp/.healthy marker; the app is otherwise stateless.
tmpfs:
- "/tmp:size=1m,mode=1777,noexec,nosuid,nodev"
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. 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 |
LOG_LEVEL |
debug, info, warn, error |
info |
No |
TZ |
Container timezone | Europe/Paris |
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
github-scout matches the fleet's scheduled-app convention — an internal timer or an external scheduler, your choice:
- 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, like the rest of the fleet. - 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, so it does not share the resident
timer's in-memory failed-run dedup set: failures still inside the lookback
window are re-emitted on each trigger. The dashboard deduplicates by run ID, so
counts stay correct — consistent with the snapshot signals (PRs / issues /
alerts), which re-emit the full open set every scan anyway.
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": "cplieger/vibekit",
"workflow": "CI",
"conclusion": "failure",
"branch": "main",
"event": "push",
"run_number": 1060,
"run_id": 12345678,
"url": "https://github.com/cplieger/vibekit/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
(for the failed-run count and the failure-rate panels). Each scan also logs a
scan complete summary line (scanned, skipped, open_prs, open_issues,
code_alerts, new_runs, new_failures, tracked, duration); a
repo-discovery failure logs at error level.
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. The
dashboard uses a standard Loki datasource (no plugins) and is organised into
"open work" (PRs, issues, alerts — current state) and "Actions runs" (recent
events — a failure-rate trend plus a conclusion-filtered run table), each with
count tiles and a linked table. Every query is built on:
{container="github-scout"} | json | msg=`open pull request`
Tables render the url field as a click-through link; the snapshot panels read
the most recent scan (a window slightly longer than the poll interval) so they
show what is open right now. Because the events are plain logs, you can also
write a Loki ruler alert (count_over_time(... [1h]) > 0) to be notified the
moment anything breaks.
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. The container starts unhealthy and flips healthy after the
first successful scan. 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.
- No persisted state. The only filesystem write is the
/tmp/.healthymarker on a smallnoexec,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.
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 restart (see Two emission models 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 |
| 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
These images are built with care and follow security best practices, but they are intended for homelab use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
Documentation
¶
There is no documentation for this package.
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. |