Configuration¶
Which repos is repos.yml, the one file you own. Everything else is
environment variables on the docker run.
The fleet is an explicit list¶
repos.yml names every monitored repo — one entry per checkout on this
machine, or one entry for a folder full of them:
repos:
- path: ~/repos/jebel-quant/rhiza
- folder: ~/repos/cvxgrp # every checkout directly inside it
- repo: Jebel-Quant/actions # monitored, but not checked out here
owner/name is read from each checkout's origin remote, so the path is all
you write. Nothing is discovered behind your back: a repo is on the board
because this file names it, or names the folder it sits in, and for no other
reason. The collector reads the file itself, at startup, from
/config/repos.yml — nothing is generated from it, so there is no second file
to fall out of step.
This replaced a whole-org GitHub sweep plus a directory walk under one mounted
root. Both decided membership on their own: a new repo in the org arrived
unasked, a shared org like cvxgrp dragged in 100+ repos that were not yours, and
any checkout that happened to sit under the root joined the board because its
origin looked right. A folder: is not that walk back: it is one directory you
wrote down, read one level deep, and the repos it holds are the repos you keep
there.
A folder of repos¶
Where you keep a whole org checked out, name the folder and every checkout in it is on the board:
Sixteen repos become one line, and the line cannot drift out of step with the
disk the way sixteen can — clone a repo into the folder and it joins at the
next docker restart jq-fleet, rm -rf one and it leaves. That is the trade:
a folder is convenient exactly because you are no longer deciding repo by repo,
so keep folders for the directories you want whole and list the repos
one by one where you want only some of them.
The rules, all of which exist so the board cannot go quietly short:
- One level, never deeper. Only the folder's own children are looked at, so
folder: ~/reposfinds nothing when your repos live in~/repos/<org>/<name>— name the folders the checkouts are directly in. Recursing would makefolder: ~the whole-disk walk this file exists to have got rid of. - A missing folder, or one with no checkouts in it, refuses to start. You
asked for the repos in it and there are none; an unreachable
pathis refused for the same reason. - A directory that is not a checkout is passed over, and so is a clone with
no
originremote to name it by — that is somebody's scratch clone, logged as a warning and skipped rather than taken as fatal. Apathnaming the same clone is still refused, because that path was a deliberate statement. -
An entry of its own wins. A folder leaves out any checkout another entry names by
path, so one repo inside a listed folder can carry arepo:override for its upstream:
Matching on the path rather than on owner/name is what makes that work,
since the point of the override is that the name comes out different. An
entry that names a repo the folder also holds (- repo: org/x) is likewise
one repo, not a duplicate: the entry decides the name and the forge, and the
folder still supplies the checkout path.
- forge: gitlab on a folder covers every checkout in it, though with
checkouts to read the origin off it is not needed at all.
Excluding one repo from a folder¶
A directory that is nearly all yours is the common case — a clone of somebody else's repo kept alongside your own, which has no business on your fleet board. Name the exception rather than listing everything else:
exclude names the folder's own subdirectories, which is the one thing
about a child that is knowable without reading its git config and the thing you
see when you list the folder — a checkout's directory need not be named after
its repo, so funnel excludes the directory funnel whatever its origin says
it is. A single name needs no brackets. Writing an owner/name there is
refused, since a folder is one level deep and there is nothing at that path for
it to mean.
An exclude with nothing to exclude is a warning, not a refusal — the one mistake in this file that is not fatal. An exclude exists to keep a repo off the board, so a folder with no such checkout in it already satisfies the request: the outcome is the one you asked for and only the line is spent, which is what deleting a clone you never wanted looks like. It is logged, because a misspelt exclude looks identical from here and that one does put an unwanted repo on the board — as a row you can see, next to the warning, rather than as the silently missing repo every other refusal here guards against.
exclude on an entry that is not a folder is refused — one repo has nothing
to exclude from, and reading the key as decoration would leave you believing a
repo was off the board while it sat on it.
JQ_IGNORE remains the way to drop a repo without editing this file at all.
Edit repos.yml, docker restart jq-fleet, and the fleet is whatever you just
wrote. Both halves of the collector read the same list, so the GitHub panels
and the working-copy panels can never disagree about who is in scope.
Archived repos are never monitored. They are dropped from the GitHub half
and their local checkouts are skipped, so a checkout left on disk cannot keep a
dead repo on the board — that gap kept rhiza-brainbug showing as the fleet's
one red repo for a while after it was archived. Set JQ_INCLUDE_ARCHIVED=true
to opt back in.
GitHub and GitLab in the same fleet¶
An entry is read through GitHub unless it says otherwise:
repos:
- path: ~/repos/jebel-quant/rhiza # github.com, from the origin remote
- path: ~/repos/acme/web # gitlab.com, from the origin remote
- repo: acme/platform/infra/web # no checkout, so it has to say
forge: gitlab
Where an entry has a path, the forge is inferred from the origin remote's
host — gitlab.com, or any gitlab.* hostname, is GitLab; everything else is
GitHub. Where it has only a repo:, there is no origin to look at, so anything
but GitHub must carry forge: gitlab. A forge nobody implements is refused at
startup rather than quietly read as GitHub, which would leave the repo on the
board with every remote panel empty and nothing saying why.
GitLab namespaces nest, and the whole path is the name. The project web in
the subgroup acme/platform/infra is acme/platform/infra/web, not
infra/web — and that whole path is what appears in the repo label and in
every dashboard row.
The same namespace/name on both forges is refused. One repo label is one
repo, and the board's entire label scheme rests on that; a merged pair would
report one repo's CI under the other's name while still looking like a working
board. Rename one entry with an explicit repo: if you genuinely have both.
Only gitlab.com is supported. Self-hosted GitLab would need the API base URL per
entry rather than one GITLAB_API, and that is not built.
One panel group cannot be filled for GitLab repos — see the Dependabot note.
Dropping a repo¶
Dropping a repo stops new samples but leaves its history, so it still appears in time windows that reach back before the change. To erase that too:
This needs Prometheus's admin API, which is off unless the container was started
with -e JQ_PROM_ADMIN_API=true — so a purge is: recreate the container with
that flag, purge, recreate it without. It is deliberately not left on: Grafana's
datasource proxy lets any viewer — and anonymous access is on — reach arbitrary
paths on the datasource, which would put "delete every metric" one request away
from a read-only visitor. purge-repo says exactly this if you run it with the
API disabled. Both label generations are purged, since repos predating the
owner/name rename have series under a bare name too.
Right after a purge, /api/v1/series may still list the series while queries
return nothing: that is stale head-block index metadata, cleared at the next
head compaction (~2h). No panel is affected, because panels run queries — and
neither is the repo picker, whose label_values resolves through the query
path. The script reports both numbers so the difference is visible rather than
alarming.
repos.yml¶
| Key | ||
|---|---|---|
path |
A checkout on this machine, written as you would write it yourself. ~ is your home directory — which the container sees as the single -v "$HOME:/host:ro" mount — and a relative path is relative to it too. |
|
repo |
owner/name. Optional next to a path — it overrides the origin, which is what you want for a fork whose board should follow upstream. On its own it monitors a repo you have not cloned: GitHub panels are gathered, the working-copy panels stay empty for that row. |
|
folder |
A directory full of checkouts. Every checkout directly inside it joins the fleet, one level deep and no further — see A folder of repos. Cannot be combined with path or repo, which describe one repo each. |
|
exclude |
Only on a folder: subdirectory names to leave out, as a list or a single name. One with nothing to exclude is warned about, not refused — see Excluding one repo from a folder. |
A bare string is shorthand for path. Duplicate entries, a path that is not a
checkout, a folder that is missing or empty of checkouts, and an entry with
neither key all stop the collector at startup — better a refusal than a board
that is quietly one repo short.
The one thing that is not fatal is a path that cannot be reached alongside
an explicit repo:. That is what running without the $HOME mount looks like,
and it is a supported way to use this: the repo keeps its GitHub panels and the
working-copy panels stay empty. It is logged as a warning, because a typo looks
identical from inside the container.
Environment¶
Passed as -e NAME=value on the docker run, or in .env if you use
docker compose.
| Variable | Default | |
|---|---|---|
GITHUB_TOKEN |
— | Must be able to read every GitHub repo in repos.yml. gh auth token prints a usable one. |
GITLAB_TOKEN |
— | Only needed if repos.yml lists a forge: gitlab entry. Public projects are readable without one; private ones need read_api. |
GITLAB_API |
https://gitlab.com/api/v4 |
Only gitlab.com is supported. |
JQ_REPO_FORGES |
— | Which forge each repo is read through, as owner/name=forge pairs. Filled from repos.yml; set it for a deployment with no file to mount. |
JQ_TEMPLATE_REPO |
Jebel-Quant/rhiza |
Whose releases define "up to date", as owner/name. |
JQ_IGNORE |
— | Repos to drop without editing repos.yml, as bare names or owner/name. Applies to both halves. |
JQ_INCLUDE_ARCHIVED |
false |
Archived repos are dropped from both halves. |
JQ_PUBLIC_ONLY |
false |
Drop private repos entirely — not just their details, their existence. |
JQ_REPO_PATHS |
— | Where each checkout really is, as owner/name=path pairs. Filled from repos.yml; set it to override one entry — see Checkout paths. |
JQ_HOST_ROOT |
/host |
Where your home directory is mounted, and therefore what ~ in repos.yml means. |
JQ_PROM_ADMIN_API |
false |
Enables the API purge-repo needs. Leave it off. |
JQ_GITHUB_INTERVAL |
300 |
Seconds between remote refreshes. Both forges are collected in one pass, so this paces both. |
JQ_MEASURE_MAX_AGE |
86400 |
Seconds an unchanged line/commit count may stand before it is retaken. See Size and cadence. |
PROM_RETENTION |
180d |
How much history to keep. |
repos.yml is read at startup and turned into the fleet the collector actually
uses, so both halves see the same list. Setting JQ_REPOS by hand works too and
is what a deployment with no file to mount — a server, or CI — does instead.
Checkout paths¶
The collector reads a repo at the path repos.yml gave for it, and nowhere
else. Paths are whatever they are on disk, so an entry like
means exactly that. Joining owner to name would produce tschm/cs and find
nothing, and that repo would drop off the working-copy panels while staying on
the GitHub ones — present on the board, and quietly missing half its columns.
Four repos in the fleet this was built against are laid out that way, which is
the whole reason repos.yml carries paths rather than deriving them.
Inside the container ~ is $JQ_HOST_ROOT, the mount point of your home
directory. That single mount is what makes an arbitrary layout expressible at
all: the per-repo bind mounts it replaced could only ever name
<root>/<owner>/<name>. To see what a path resolved to, read jq_local_*
series' path label at http://localhost:9109/metrics, or the startup line:
JQ_REPO_PATHS overrides the file, one repo at a time, for the case where a
path is right everywhere except in the container. A path containing a comma
cannot be expressed there — comma is the separator — which is one more reason
repos.yml is where the layout is normally written down.
API budget¶
A refresh costs roughly 6 × repos + workflows + open PRs REST calls. Measured
on this fleet of 25: 370 calls for a steady-state refresh, or ~2220/hour at
the default 600s cadence, against an authenticated budget of 5000.
An earlier version of this page said 119 calls and ~1430/hour. That was understated; the numbers above were measured by counting requests through a full refresh rather than derived from the formula.
Two caches keep it there. A cold start adds one pointer read per repo; after
that the sha-cache skips them — measured 32 pointer reads on the first refresh
and 1 on the second, that one being the single repo whose default branch
had moved. Coverage adds one artifact listing per repo (25 calls, ~150/hour),
but the report itself — a zip, the largest response here — is downloaded only
when the artifact id changes: 19 downloads cold, 0 on the next refresh. jq_github_rate_limit_remaining is on the Collector health row so the
headroom is visible rather than assumed. Lower JQ_GITHUB_INTERVAL only if that
number stays comfortable.