How the worker is built, and why. For using it, see the README.
Every request goes through one GET chokepoint in
github_api.py — the only module in the package that so much as imports httpx — and a CI
guard (tests/test_readonly_guard.py) fails the build if a write verb appears anywhere. That
matters more here than on most APIs: GitHub's write endpoints share a host, a path and an
Authorization header with the reads, so a token with write scope would happily authorize a
POST. Names from user SQL are validated and percent-encoded so they cannot leave their path
segment, and pagination links — which are response data — are followed only while they stay
on the configured API origin, so a hostile Link header cannot walk the token off GitHub.
git clone https://github.com/Query-farm/vgi-github
cd vgi-github
uv sync --all-extras # Install dependencies
uv run pytest # 128 offline tests
uv run ruff check . # LintDependencies resolve from PyPI, so a fresh clone works with no sibling checkouts. To develop
against a local vgi-python or vgi-rpc, install them over the top rather than adding a path
source to the committed manifest:
uv pip install -e ../vgi-python -e ../vgi-rpcFunctions come in two shapes, and the job decides which.
Search is a streaming scan. search_repositories and search_issues are where a question
starts, so they emit one page per tick with GitHub's next link held in scan state: rows
arrive immediately, and LIMIT 10 costs one request of search's 30-a-minute budget rather than
ten. A streaming scan cannot be the inner side of a correlated LATERAL — drive a join from
it.
Everything keyed is blended (RowTransformFunction): its positional arguments are
per-row input columns, so one registration serves a literal call and a LATERAL alike.
| Function | Positional (per-row) | Named |
|---|---|---|
search_repositories(query) (scan) |
GitHub search syntax | sort, order |
search_issues(query) (scan) |
GitHub search syntax | sort, order |
repo(repo) |
'owner/name' |
cache_ttl |
repos(owner) |
login | type, sort, max_rows, cache_ttl |
user(login) |
login | cache_ttl |
issues(repo) |
'owner/name' |
state, labels, since, include_pull_requests, max_rows, cache_ttl |
pulls(repo) |
'owner/name' |
state, base, max_rows, cache_ttl |
issue_comments(repo, number) |
both | max_rows, cache_ttl |
commits(repo) |
'owner/name' |
sha, path, author, since, until, max_rows, cache_ttl |
releases(repo) |
'owner/name' |
max_rows, cache_ttl |
contributors(repo) |
'owner/name' |
max_rows, cache_ttl |
languages(repo) |
'owner/name' |
cache_ttl |
stargazers(repo) |
'owner/name' |
max_rows, cache_ttl |
workflow_runs(repo) |
'owner/name' |
branch, event, status, max_rows, cache_ttl |
A repository is always one string, 'owner/name', and every row that belongs to one carries it
in a repo (or full_name) column. That is the whole composition story — the output of any
function is the input of the next:
-- Where a project's top contributors say they work
SELECT c.login, c.contributions, u.company, u.location
FROM github.contributors('duckdb/duckdb', max_rows => 10) c,
LATERAL github.user(c.login, cache_ttl => 3600) u
ORDER BY c.contributions DESC;
-- The conversation on the most discussed recent issue — a two-column key
SELECT c.user_login, c.created_at, left(c.body, 80) AS excerpt
FROM (SELECT repo, number FROM github.issues('duckdb/duckdb', max_rows => 20)
ORDER BY comments DESC LIMIT 1) i,
LATERAL github.issue_comments(i.repo, i.number) c;
-- CI failure rate per workflow
SELECT name, count(*) FILTER (WHERE conclusion = 'failure') / count(*) AS failure_rate
FROM github.workflow_runs('duckdb/duckdb', status => 'completed')
GROUP BY name ORDER BY failure_rate DESC;Output rows carry parent_rows provenance back to the input row that produced them, so
correlated columns line up; a key repeated within one input batch is fetched once.
Every list function is capped, at 100 rows per input row by default. A blended function has
to emit everything for its input in one process() call, and a call blocked inside its first
batch cannot be cancelled. On a paged endpoint, an uncapped walk is therefore not merely slow —
it wedges the client until it finishes. max_rows defaults to 100, which is exactly one
request per input row, newest first; max_rows => 0 walks to the end, bounded at 10,000 rows,
past which it raises GitHubPageLimitError rather than returning a prefix that reads like a
complete result. vgi-lint's VGI911 caught an example that broke this rule — an uncapped walk
of every open issue in duckdb/duckdb — before it shipped.
Filter with arguments, not WHERE. DuckDB does not push a WHERE clause into a
table-in-out function — EXPLAIN shows the FILTER sitting above the scan — so a predicate
only filters rows already fetched, and cannot reach past max_rows:
-- Wrong: only the closed ones among the 100 newest issues — 18 rows when this was written
SELECT * FROM github.issues('duckdb/duckdb') WHERE state = 'closed';
-- Right: GitHub filters before the cap
SELECT * FROM github.issues('duckdb/duckdb', state => 'closed');| Function | Filters GitHub applies before the cap |
|---|---|
issues |
state, labels, since, include_pull_requests |
pulls |
state, base |
commits |
sha, path, author, since, until |
repos |
type, sort |
workflow_runs |
branch, event, status — a lifecycle status or a conclusion such as 'failure' |
search_* |
everything: put qualifiers (org:, label:, is:open, stars:>1000) in the query |
This was found the hard way. An early build declared filter pushdown on the blended functions and
translated WHERE state = ... into GitHub's state parameter; the translation never ran,
because the filter never arrived, and WHERE state = 'closed' returned nothing at all.
To rank a whole history, let search rank it. "The most thumbs-up open issues ever" is not a
job for max_rows => 0 over thousands of issues — it is one request:
SELECT number, title, reactions_plus_one
FROM github.search_issues('repo:duckdb/duckdb is:issue is:open', sort => 'reactions-+1')
LIMIT 10;issues() is issues. GitHub's issue listing returns pull requests too. They are dropped by
default and max_rows counts only the issues kept; include_pull_requests => true keeps them,
flagged by is_pull_request. state defaults to all rather than GitHub's own open, so an
unfiltered call means what an unfiltered SELECT means.
Merged is not a state. A merged pull request has state = 'closed' and a non-NULL
merged_at; one closed unmerged has merged_at NULL.
SELECT median(date_diff('hour', created_at, merged_at)) AS median_hours_to_merge
FROM github.pulls('duckdb/duckdb', state => 'closed')
WHERE merged_at IS NOT NULL;A key that names nothing yields no rows, not an error. A deleted repository, a private one
read without a token, an empty repository's commits (GitHub answers 409) — each produces zero
rows for that input row, so one bad name does not sink a whole LATERAL. A missing page
mid-walk is still an error: the object existed a moment ago, and a short result would look
whole.
stargazers() only works on repositories you administer. GitHub restricts the listing: for
anyone else's repository it refuses even though the repository plainly exists — 404 for a user
token, 403 for a GitHub App or Actions token, 401 anonymously. An empty result would read as "no stars", so this one function raises an
explanatory error instead. repo() still reports stargazers_count for any repository. Note
also that stargazers are listed oldest first, so the default cap returns the first hundred.
Next links are followed by origin, not by path. A repository's issues page hands back a
next link to /repositories/{id}/issues?...&after=<cursor> — not the path that was asked for
— so the check is that the link stays on the configured API scheme, host and path prefix.
Following it also has to preserve its query exactly: httpx replaces a URL's query string when
handed params, even an empty list, which silently turned page two of state=closed into page
one of open issues. tests/test_api.py pins both.
GitHub's own quirks are kept, and documented on the column. open_issues_count counts open
pull requests too; watchers_count is really the star count; a renamed repository redirects and
repo() returns its current full_name.
Blended-function constraints. Positional args are read off batch, not params.args; a
positional const arg is rejected, so every optional knob is a named arg; and no function may
define finalize/finish, because DuckDB forbids FinalExecute under correlated LATERAL. A
named argument cannot receive a correlated column either — LATERAL f(x => t.col) does not bind
— so pass a literal or a scalar subquery.
GitHub publishes generous headers and the worker uses all of them.
Conditional requests. Every response carries an ETag, and a 304 Not Modified to an
authorized request is not charged against the primary rate limit. The worker keeps a bounded,
in-process cache of (request, token) → (ETag, body) and revalidates instead of refetching. A
LATERAL over five repositories charged 19 requests on its first run and 0 on a repeat
served by the same worker process. The cache is per process and the extension keeps a small pool
of them, so a repeat that lands on a cold process pays once. Entries are partitioned by a hash
of the token — a body fetched with one token is never replayed to another, or to an anonymous
caller — and bodies are re-decoded on every hit, so mutating rows cannot corrupt the cache.
Result cache. GitHub's Cache-Control is forwarded to DuckDB's result cache rather than
invented. Anonymous responses say public, max-age=60 and are cached for 60 seconds.
Authenticated responses say private — what a token can see is specific to it — and a
private response is not reusable by a shared cache, so they are not cached unless you pass
cache_ttl => N, which also turns on per-value memoization for a LATERAL that repeats keys.
Rate limits. GitHub says when you may retry, and the worker listens: Retry-After on a
secondary limit, X-RateLimit-Remaining: 0 plus the reset epoch on the primary one. A wait of up
to 15 seconds is slept through; anything longer fails at once with the reset time and, for an
anonymous caller, how to authenticate. Fifteen seconds is deliberately short — the sleep happens
inside an uncancellable process() call, and even search's one-minute window is too long to
block a client for. Transient 5xx responses and dropped connections are retried with exponential
backoff.
| Budget | Anonymous | With a token |
|---|---|---|
core — everything but search |
60 / hour | 5,000 / hour |
search |
10 / minute | 30 / minute |
SELECT resource, remaining, request_limit, reset_at, authenticated FROM github.rate_limit;Everything a client sees on ATTACH — object descriptions, column comments, result schemas,
examples, categories, agent test tasks — is published as vgi.* tags and checked by
vgi-lint:
vgi-lint lint # config lives in vgi-lint.toml
vgi-lint lint --audit-waivers # prove the waivers still buy somethingThe catalog scores 99/100 with no findings, structural and behavioural — the behavioural tier executes every documented example against the live API.
Column documentation has a single source: vgi_github/schemas.py attaches a comment to every
Arrow field via meta.field(), and meta.result_columns_schema() reads those same strings back
out to build each function's declared result schema. A column documented once shows up in
DESCRIBE, in duckdb_columns() and in vgi.result_columns_schema, and cannot drift between
them.
Three waivers live in vgi-lint.toml, each with a recorded kind and reason that
--audit-waivers re-checks. VGI311 asks that a parameterless scan be exposed as a table, which
all_rate_limit is — as rate_limit; the rule matches on name, and a function and a table
cannot share one. The other two are on stargazers, and both come from GitHub listing
stargazers only to tokens that administer the repository: no agent test task can be written
that every caller's credentials can complete (VGI520), and VGI911's bare-LIMIT probe sees the
function's deliberate error for any token without that access — CI's workflow token included.
.github/workflows/ci.yml runs on every push: ruff (lint and format), the offline tests, a check
that both entry-point scripts start without the dev checkouts, and vgi-lint's structural tier
with --audit-waivers --fail-on warning. All of it resolves from PyPI (UV_NO_SOURCES=1), so it
also proves the published dependencies are sufficient.
.github/workflows/live.yml runs daily, never concurrently, and is the half that touches GitHub:
live checks of GitHub's own behaviour, the end-to-end SQL suite against a real ATTACH, the
same worker served over HTTP to several
clients at once, then vgi-lint --execute, which runs every
shipped example. It authenticates with the workflow's own read-only GITHUB_TOKEN, passed as
VGI_GITHUB_TOKEN.
It earns its keep. The live tier found, in this worker's first day, a token that never reached
the functions, a WHERE that DuckDB never pushed down, pagination that dropped its own filters,
and an example that wedged the client — none of which an offline test could see.
uv run pytest # 128 offline tests
VGI_GITHUB_TOKEN=$(gh auth token) uv run pytest -m live # 48 tests against the real APItests/test_live.py pins the GitHub behaviour the design rests on, with no DuckDB involved:
next links pointing at /repositories/{id} and carrying their query, page size capped at 100,
search answering page 11 with a 422, the issue listing mixing in pull requests, a 304 not being
charged, authenticated responses being private, and stargazer listings being refused for other
people's repositories. If GitHub changes one of these, this tier goes red before a user sees a
wrong answer.
tests/test_functions.py drives every function's process() as DuckDB would — a batch of input
rows in, one batch with provenance out — against a mock GitHub. tests/test_api.py pins the
chokepoint: retries, rate-limit waits, origin-checked next links, the query string surviving a
followed link, and token-partitioned ETags. tests/test_auth.py covers secret selection by type
and scope, and that an ambient GITHUB_TOKEN is never borrowed. tests/test_http_transport.py
serves the worker over HTTP and attaches several clients to one server — the server holds no
token of its own, so it checks that a client's secret crosses the wire, and that it never
authorizes another client: an anonymous client stays anonymous even after a token client has
warmed every server-side cache, and with VGI_GITHUB_PRIVATE_REPO set it checks a private
repository stays invisible to the anonymous one. tests/test_packaging.py
checks the entry-point scripts' PEP-723 headers still cover every runtime dependency — they
resolve independently of pyproject.toml, so they drift silently and only an end-to-end
ATTACH notices.