forge-scout puts the open pull requests, open issues and failing CI workflows across your repositories on GitHub, GitLab, Gitea and Forgejo on one Grafana dashboard, with a link on every row, and adds code-scanning alerts on GitHub. It only reads from your forges.
forge-scout shows you what is waiting for you across all your repositories and forges, in one place:
- Lists open pull requests and issues, idlest first, with who opened them, how old they are and whether a pull request's checks fail.
- Lists the workflows whose latest run on the default branch failed, how many runs in a row, since when and when they last ran.
- Logs every finished CI run, the slowest workflows and the workflows GitHub disabled.
- Lists open GitHub code-scanning alerts, colored by severity.
- Adds a new repository or workflow on the next scan, with nothing to configure.
- Marks the counts a scan could not read whole, and the signals a forge does not have, so the dashboard never shows an unchecked count as 0.
forge-scout is built for people with repositories on one or more forges, private ones included: github.com or GitHub Enterprise Server, gitlab.com or a self-managed GitLab, and Gitea or Forgejo instances. It checks every repository of the owners you name every 15 minutes.
forge-scout reports through its log, so its dashboard and alerts come from your Grafana, Loki and Alertmanager, which the monitoring guide sets up. You also need an access token for each forge, read-only where the forge allows it.
Other tools suit other needs:
- Consider the Grafana GitHub data source if you want Grafana panels that query the GitHub API directly for your repositories and projects.
- Consider gh-dash if you want to work through GitHub pull requests and issues from a terminal, with diff, comment and checkout built in.
forge-scout is free software under the GPL-3.0-or-later license.
The image is on GitHub Container Registry and Docker Hub, for amd64 and arm64. This is the compose.yaml in this repository.
services:
forge-scout:
image: ghcr.io/cplieger/github-scout:latest
container_name: forge-scout
restart: unless-stopped
# Must own the ./data host folder. If .env sets PUID and PGID, those numbers are used.
user: "${PUID:-1000}:${PGID:-1000}"
environment:
# Every token the config file names as ${...}. Put each in a .env file beside this file.
# The github connection in config.example.yaml reads this token. Delete this line when you remove that connection.
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
# The forgejo connection in config.example.yaml reads this token. Delete this line when you remove that connection.
FORGEJO_TOKEN: "${FORGEJO_TOKEN:-}"
volumes:
# Run "mkdir -p config && cp config.example.yaml config/config.yaml", then edit
# config/config.yaml before the first start.
- "./config:/config:ro"
# The run state. Run "mkdir -p data && sudo chown 1000:1000 data" before the first start.
# If .env sets PUID and PGID, use those numbers.
- "./data:/data"- Create a token on each forge, with the permissions for that forge. On GitHub, a fine-grained token with read-only Actions, Contents, Commit statuses, Pull requests, Issues and Code scanning alerts is the least access. GitHub offers fine-grained tokens no Checks permission, so the checks of pull requests on private repositories show as Not available with this token. Only a classic token with the
reposcope reads them, and that scope grants write access too. A fine-grained token also reads the private repositories of only one resource owner, so private repositories under several owners need a classic token as well. It reads code-scanning alerts only on its resource owner's repositories, public ones included, so code scanning on another owner's public repositories needs a classic token too, or those repositories insecurity.skip_repos. Choosing a GitHub token weighs the two. - In the folder that holds
compose.yaml, create a file named.envwith one line per token, such asGITHUB_TOKEN=github_pat_your_token. If your user on the host is not uid 1000, also addPUID=andPGID=lines with your numbers fromid -uandid -g. - Save
config.example.yamlbesidecompose.yaml, then runmkdir -p config && cp config.example.yaml config/config.yaml. Inconfig/config.yaml, set each connection'surlandowners, and delete the connections you do not use. - Add each token variable the config names to the
environment:block ofcompose.yaml. - Run
mkdir -p data && sudo chown 1000:1000 data, with yourPUIDandPGIDin place of 1000 if you set them, thendocker compose up -d. - Have your log collector send the container's logs to Loki with the label
container="forge-scout", which every dashboard panel selects on. - In Grafana 13.2 or newer, import
grafana-dashboard.json. It reads from your default Loki data source.
Run docker logs forge-scout. You should see one line with "msg":"scan complete" and "degraded":false for each connection. If a forge rejected the token or could not be reached, its connection logs "msg":"repo discovery failed", then "msg":"scan degraded", and still ends with "msg":"scan complete", but with "degraded":true. The connection field names which one. A connection that stopped early logs "msg":"scan stopped" and no scan complete.
The settings are one YAML file, read once at start, so recreate the container after a change. Its path is /config/config.yaml, or the CONFIG_PATH variable. A value written as ${NAME} is read from the environment when the name ends in _TOKEN or starts with FORGE_SCOUT_. Each token must be one such reference and nothing else, so no token is ever written into the file. An unknown key, or a second document in the file, stops the start with an error naming it.
The container reads these environment variables:
| Variable | Description | Default |
|---|---|---|
CONFIG_PATH |
Path of the config file inside the container | /config/config.yaml |
Each ${NAME} the file names |
The value of that reference, such as GITHUB_TOKEN. Set each one in the environment: block |
none |
PUID, PGID |
The user and group the container runs as, who must own ./data. compose.yaml reads them, not forge-scout |
1000 |
The file takes these keys:
| Key | Default | Description |
|---|---|---|
scan_interval |
15m |
Time between scans, such as 15m or 1h, from 1 minute to 171 hours |
lookback |
72h |
How far back every scan reads CI runs, from 1h to 720h, at least 4.2 times scan_interval: two scan_interval, each up to 10% late, plus one scan at its limit of twice scan_interval. A re-run is seen while its run is younger than lookback |
log_level |
info |
debug or info. warn and error stop the start, because every line the dashboard reads is logged at info |
exclude |
none | Rules that leave out open pull requests and issues on every connection. Configuration describes them |
connections |
required | One entry per forge instance, keys below |
Each connection takes these keys:
| Key | Default | Description |
|---|---|---|
name |
required | The label every log line carries as connection. Lowercase letters, digits, - and _, up to 32 characters |
url |
required | The address you open the forge at, such as https://github.com |
api_url |
derived from url |
The API address, for an instance that serves it somewhere else |
token |
required | A read-only token, written as exactly one ${NAME} reference to an environment variable |
owners |
required | The users, organizations or GitLab groups whose repositories are scanned |
exclude_repos |
none | Full owner/name paths left out of every signal |
exclude |
none | More rules like the top-level exclude, applied to this connection after those |
bot_authors |
none | Logins shown as bots although the forge counts them as people, such as a self-hosted Renovate's user account |
security |
enabled: true, skip_forks: true, include_private: false |
enabled, skip_forks, include_private and skip_repos for code-scanning alerts |
private_addresses |
false |
Allow an instance on a private or loopback address |
allow_plaintext |
false |
Allow an http:// address |
forge-scout detects which product each connection runs, so nothing in the file names it. Configuration explains the tokens, the owners, the exclusions and the one-shot trigger command.
Pull requests, issues and CI runs are read on every forge. Code-scanning alerts and disabled workflows exist on GitHub alone, so on the other forges those counts are left out of the log and the dashboard shows them as not available, never as 0.
- On Gitea and Forgejo, the run listing has no time filter. forgeapi ends it at the first page whose runs were all created more than a minute before the listing's start, so a long run history costs one or two requests a scan. Gitea releases before 28 send no run creation time, so their runs are not read and the log says so once per scan.
- Run durations need a start time from the forge. GitHub, Gitea and Forgejo send one. GitLab's pipeline list does not, so GitLab workflows never appear among the slowest workflows.
- A GitLab owner that is a user and not a group has no owner-wide pull request or issue list. forge-scout then reads each of that user's projects, which costs two requests per project per scan.
- A pull request's checks are read on GitHub and GitLab, one request per open pull request. GitHub refuses a fine-grained token the checks of a pull request on a private repository, and the dashboard shows them as Not available with this token. Only a classic token with the
reposcope reads them, as Choosing a GitHub token explains. Gitea and Forgejo name no head commit in the cross-repository list, so their pull requests show no checks.
forge-scout opens no port and runs no web server. It sends only read requests, and only to the forges you configure, so give it the least access each forge's token allows. Each token travels only in the request header to its own forge and never appears in the log, which records only whether a token is set. Keep .env out of git.
forge-scout reads at most 8 MiB of each response body, on every forge and for its own GitHub code-scanning and workflow reads alike. A larger response fails that read, so a hostile or broken server cannot fill its memory. One repository's code-scanning alerts are held to 8 MiB too, across all their pages: past that, the repository's code-scanning read fails.
forge-scout holds back 250 requests of the budget each forge's last response reported. A scan that would go below that stops for that forge, logs scan stopped and reads again once the budget renews, so it does not use up a budget your other tools share. The dashboard keeps that connection's last complete scan meanwhile, dated by As of. GitHub keeps separate budgets for its REST and GraphQL APIs and reports only the one a request drew on. There forge-scout holds the 250 against the REST budget that all of the connection's reads share, and forgeapi holds the GraphQL budget itself. A GitHub answer of 403 with no rate-limit header stops every request of that connection for the rest of the scan, because GitHub sends it for a secondary rate limit too.
The image is distroless, with no shell, and runs as a non-root user. It writes only to /data, where it keeps the run state, and to /tmp, where it keeps a health marker. Security has the hardened compose settings and what the image contains.
The healthcheck runs /forge-scout health, which checks that the scan loop refreshed its marker file within the last three scan intervals plus one scan at its limit of twice the interval, 75 minutes at the default. Unhealthy means the loop stopped, so restart the container. The container stays healthy through a failed scan, a rejected token or a spent budget. Those appear in the log, and the bundled alert rules fire on them.
- If the container exits at start with
invalid configuration, the error names the key or the connection to fix. A missing file names/config/config.yaml. - If the log shows
"cause":"token_invalid", that forge rejected the token. Check that it has not expired. - If the log shows
"cause":"owner_unresolved", that forge does not know one of theowners. Thereasonfield names it. - If the log shows
owner lists no repository, theownersfield names owners that exist but hold no repository this token can see. A misspelt owner that happens to name another account shows up here, as does a token that cannot see the owner's private repositories, and a GitLab user whose profile is private. - If the log shows
uses the same account as, two connections to one forge use tokens of the same account. List both connections' owners in the first one and remove the second. - If every scan logs
code scanning unreadablefor the same repository and"cause":"github_refused", GitHub refuses that repository's code scanning, as it does for a private repository without GitHub Advanced Security oncesecurity.include_privateis on. Add its path tosecurity.skip_repos. - If the log shows
"cause":"github_refused"afterruns listing failedorpull request checks unreadable, the token lacks a permission on that repository. Itshintfield names the one to grant. - If the log shows
runs coverage gap, forge-scout was stopped for longer thanlookback, or its run lists of that repository stayed cut short for that long, and the CI runs created in thehoursbefore the window were not read. Raiselookbackif such gaps are expected. When the line carriespending_since, a list cut short no longer reached a run still in progress at the scan before, so that run's result may never be logged. - If the log shows
run state unreadable; starting cold, the file in/datacould not be used and was moved to theset_asidepath, so the first scan reports the whole lookback window again. Theerrorfield says why. Check that./databelongs to thePUIDandPGIDthe container runs as, 1000 unless.envsets them. - If the container exits at start with
run state directory unusable, forge-scout could not create its lock file in/data. Theerrorfield says why. Check that./dataexists and belongs to thePUIDandPGIDthe container runs as. - If the log shows
run state save failed,/databecame unwritable or full after forge-scout started. Each scan logs"cause":"state_unwritable". CI runs are still logged, and a restart before a save succeeds logs them again. Theerrorfield says why. - If the log shows
run state not durable, the file system under/datacannot sync a folder, as some network shares cannot. Each scan logs"cause":"state_not_durable". CI runs are still reported, but a power loss can make the next start report some of them again. Put/dataon a local disk. - If the container exits at start with
state directory is in use by another forge-scout process, another forge-scout uses the same/data. Give each container its own folder, and runtriggeronly when the daemon is stopped.
forge-scout writes one JSON log line for each open pull request, issue, alert and observed run state, plus a scan complete summary for each connection after each scan. The bundled dashboard needs Grafana 13.2 or newer and reads those lines from Loki, and two Loki alert rules fire when scans go blind or stop. Monitoring and alerts lists the log fields, the dashboard and the rules.
- Configuration covers the token permissions, the owners, the exclusions and one-shot scans.
- Monitoring and alerts covers the log lines, the Grafana dashboard and the alert rules.
- Security covers the hardened compose settings and what the image contains.
- How forge-scout works covers scanning, the run state and API use.
forge-scout reads every forge through forgeapi, and GitHub code scanning and workflows through the GitHub REST API. The way that GitHub client authenticates, sets the API version and pages through results follows two Prometheus exporters for GitHub, githubexporter/github-exporter and xrstf/github_exporter. THIRD_PARTY_NOTICES.md names each pattern.
See CONTRIBUTING.md.
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, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
GPL-3.0-or-later. See LICENSE. The image carries the license text of every bundled component under /usr/share/licenses/.
Third-party attributions are in THIRD_PARTY_NOTICES.md.
