The pulse of a GitHub repository. bilan turns pull request history into a picture of what is actually happening in a repository: who is contributing, which parts of the codebase are moving, how reviews flow and who carries them, and where work gets stuck.
See the codeThe pulse of a GitHub repository.
bilan turns pull request history into a picture of what is actually happening in a repository: who is contributing, which parts of the codebase are moving, how reviews flow and who carries them, and where work gets stuck. It is also a way to see what AI-assisted coding does to a codebase and a team over time: how much gets opened, how large the changes are, how fast they merge, how many carry a real review, and how review load lands on the humans.
Every latency is measured from the moment a pull request became ready for review, so time spent in draft is never charged to reviewers. bilan reads pull request metadata only, never code, and never writes to GitHub.
Two ways to use it:
npx github-bilan owner/name --open
The command syncs the repository into a local cache, writes
<name>.report.html, and opens it. Re-running is cheap: the sync is
incremental.
| Flag | Effect |
|---|---|
--open | Open the report in the browser when done |
--out FILE | Report path (default <name>.report.html) |
--since DATE | Reach further back than the default 30 days |
--full | Walk the entire history |
--max-prs N | Stop after N pull requests |
--no-cache | Ignore the local cache and fetch everything again |
--offline | Render from the cache without contacting GitHub; rejects sync options |
--token T | GitHub token; otherwise GITHUB_TOKEN, GH_TOKEN, then gh auth token |
--areas FILE | List the folders that are areas, nested ones included |
--no-cache builds a new cache beside the old one (<name>.json.fresh) and
only replaces it once the fresh sync finishes; a fresh sync stopped by the rate
limit, --max-prs or an interruption keeps the old cache, which the report then
shows, and running --no-cache again continues it. One fresh sync of a
repository runs at a time.
--offline only renders what is cached, so it rejects the sync options
--no-cache, --full, --since, --max-prs and --token; tokens in the
environment are ignored.
--areas replaces the inferred areas with a JSON file of folder paths from the
repository root, nested ones included:
{ "known": ["docs", "packages/app", "packages/integrations/node"] }. A changed
file counts toward the deepest listed folder holding it, other nested files
toward other, and files at the root toward root. Lockfiles, .changeset/
and release or dependency pull requests still count toward no area.
By default a sync covers the last 30 days of activity plus every open pull
request; later runs only widen that coverage. GitHub reports each request's
rate-limit cost, a few points per page of 25 pull requests; long review-request
histories need extra requests. The CLI reports points spent and stops near the
rate limit or at --max-prs. The next run first re-checks pull requests updated
since the stopped run began, then continues from where it stopped, so a history
too large for one rate-limit window finishes over several runs. The cache lives in ~/.cache/bilan, or $XDG_CACHE_HOME/bilan when set.
BILAN_CACHE_DIR can select a dedicated cache directory; empty values use the default.
An existing directory is only adopted when it is empty or holds a bilan cache
(older ones are recognized by their snapshots); anything else is rejected before
its permissions change. On Unix, cache directories
are owner-only (0700), and cache files and HTML exports are written with
owner-only permissions (0600), including when replacing older files.
The web app, at bilan.dev, runs on Cloudflare Workers
with D1, R2, KV, and Workflows. Signed out, it shows two built-in example
dashboards (withastro/astro and
cloudflare/workers-sdk) shipped as
static snapshots, plus the last sync of any public repository bilan already
holds, once the server token confirms it is still public; signing in is needed
to refresh it or sync more history. Private repositories always ask the viewer
to sign in, whether or not bilan holds them. Signed in, it syncs any repository you can read on your own
token into a shared cache, so a coworker who opens the same link sees the
dashboard instantly. A new repository shows a Start sync button; following
a link alone does not import its data. Existing dashboards can refresh during
navigation within the app. External links and bookmarks use the Refresh
button for stale data.
Access is checked per viewer against GitHub with the viewer's own token; allowed access and public visibility are cached for up to 15 minutes. Job progress requires repository access too, even for an old completed job. A private repository the viewer cannot read is indistinguishable from one that does not exist. Private data nobody has opened in 90 days is deleted.
Login is a GitHub App with read-only permissions (Pull requests, Metadata). User tokens expire after eight hours; bilan stores them encrypted and refreshes them, including mid-sync. Private repositories require the app to be installed on the organization, once, by an owner; members can request it from the app's install page. Public repositories need no install.
Installation callbacks without browser-bound OAuth state restart normal sign-in; their supplied authorization code is never used to create a session. Authenticated responses are not browser-cacheable, and dynamic responses disallow framing.
Sync admission is atomic in D1: one active job per repository, at most two
active jobs and 20 starts per account in a rolling 24 hours, and at most 20
active jobs and 200 starts globally. Deepening history still consumes these
budgets; failed attempts count toward the daily budget. A repeated sync of the
same depth has a 10-minute cooldown. The limits live in
packages/store-d1/src/jobs.ts. Abandoned reservations are reconciled before
admission, and a stalled live workflow must be terminated before its capacity
is released. If the engine cannot report its status, the reservation is kept.
Create a GitHub App with permissions Pull requests: Read-only and Metadata:
Read-only (and Members: Read-only under organization permissions, to resolve
team reviewers), "Request user authorization during installation" enabled,
expiring user tokens enabled, no webhook, and the callback URL of your
deployment plus http://localhost:8788/auth/github/callback for local use.
Put the app's slug in GITHUB_APP_SLUG in packages/web/wrangler.jsonc.
Create the Cloudflare resources and record their ids in wrangler.jsonc:
cd packages/web
npx wrangler d1 create bilan
npx wrangler r2 bucket create bilan-payloads
npx wrangler kv namespace create CACHE
Set the secrets and deploy:
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
openssl rand -base64 32 | npx wrangler secret put TOKEN_ENCRYPTION_KEY
npx wrangler d1 migrations apply bilan --remote
cd ../.. && pnpm run deploy
The deploy runs through turbo, which builds the dashboard and its
dependencies before wrangler deploy.
GITHUB_TOKEN, a classic token with no scopes, is optional: it is used as a
fallback for public repositories a user token cannot reach, and to confirm a
repository is still public before showing its dashboard to signed-out
viewers. Without it, a signed-out viewer only sees a public dashboard in the
15 minutes after a signed-in viewer's check confirmed it. A token GitHub
rejects (expired or revoked) is logged and treated as not configured.
Every push to main that passes CI is deployed by the Deploy workflow,
which applies pending D1 migrations and then runs wrangler deploy. It needs
two repository secrets: CLOUDFLARE_API_TOKEN (the "Edit Cloudflare Workers"
template plus D1 Edit) and CLOUDFLARE_ACCOUNT_ID.
The deploy gate verifies that the triggering run was a push to the canonical
repository's main, not a pull request or a fork's branch of the same name.
Cloudflare credentials are available only to the migration and deploy steps.
For local development, copy packages/web/.dev.vars.example to .dev.vars,
fill in the values, run npx wrangler d1 migrations apply bilan --local, then
pnpm --filter @bilan/web dev. Workflows require the Workers Paid plan.
The built-in examples are regenerated with pnpm examples:build, which needs
a GitHub token.
Each sync publishes gzip-compressed dashboard snapshots to R2 under
payload/<repo-id>/<sync-timestamp>.json.gz. The daily job at 06:00 UTC keeps
the snapshot referenced by D1's last_synced_at, regardless of age, and any
snapshot uploaded within the past 48 hours. It deletes older, superseded
snapshots only after verifying that the current snapshot exists in R2. If the
repository row, current reference, or current R2 object is missing, all older
snapshots are kept as possible recovery copies. Unknown key formats are also
left alone. The existing 90-day inactive private repository
cleanup still removes those repositories and all their snapshots.
To review the backlog from the repository root, authenticate with Wrangler
for the Cloudflare account configured in packages/web/wrangler.jsonc, then run:
pnpm --filter @bilan/web payloads:prune
This defaults to a dry run against production. It prints each candidate's
compressed size and key, then totals for objects scanned, candidate objects,
and reclaimable bytes. skippedUnverified counts old objects kept because no
current snapshot could be verified. It does not run the private repository/session cleanup.
To perform the deletions, re-evaluating candidates against D1:
pnpm --filter @bilan/web payloads:prune --apply
Both paths process up to 1,000 objects at a time and re-read each repository's
current reference before deleting its batch. Errors stop the run; a later run
can safely resume by scanning the remaining objects. Use --local to work with
local Wrangler state instead of production, or --help for the command options.
| Metric | Window anchor | Definition |
|---|---|---|
| PRs opened | created | |
| Merged / closed unmerged | merged / closed | |
| Merged PRs by area | merged | per area and week, or month over all time; counted in every area touched |
| Time in draft | created | opened → marked ready (draft-opened pull requests only) |
| Time to first review | ready | ready → first review by someone other than the author |
| Time to merge | merged | ready → merged |
| Reviewer turnaround | review | review request (or ready, if never requested) → that reviewer's first review |
| Reviews given | review | self-reviews excluded |
| Review threads per PR | merged | review threads ÷ pull requests merged, by size |
| Approved with no threads | merged | every review was an approval and no review thread was opened |
| Time to approval | merged | ready → first approval, by size |
Areas are folders, read from up to 30 changed file paths per pull request; a
pull request touching two areas counts in both. Each top-level folder is an
area, except that a folder touched by more than half the pull requests, such as
packages/ in a monorepo, is split into its packages: the outermost folders,
at most three deep, holding a sampled package.json, Cargo.toml, go.mod or
pyproject.toml, or its subfolders when it holds none. A folder stays whole
when one piece would still hold more than 90% of its pull requests. Lockfiles
and .changeset/ count toward no area, and neither does a pull request whose
sampled files are only those, manifests and changelogs, as release and
dependency pull requests are. Files at the repository root count as root.
A pull request that opened as a draft becomes reviewable at its first ready-for-review event; later re-drafts are ignored. Each metric is scoped by its own anchor, so "last 30 days" means what happened in those 30 days rather than which pull requests were opened in them. Up to 40 reviews are captured per pull request.
Review depth groups merged pull requests by size, in lines added + deleted: ≤50, 51–250, 251–500, 501–1k, and over 1k. Its thread count is GitHub's total per pull request, which does not say who opened each thread, so threads opened by bot reviewers (such as Copilot) count too and overstate human discussion.
Draft transitions are fetched separately from review requests, and review-request histories are paginated without a fixed event limit. Run --full to refresh all cached PR metadata after upgrading.
pnpm install
pnpm check # oxfmt, oxlint, tsc, vitest across the workspace
pnpm build # bundles the dashboard, the CLI, and the web app
node packages/cli/bin/bilan.mjs owner/name --open
| Package | Role |
|---|---|
packages/core | GitHub GraphQL client, sync engine, derive step, and metrics; Web APIs only |
packages/ui | The dashboard as a framework-free module |
packages/store-file | JSON-file sync store used by the CLI |
packages/store-d1 | Drizzle schema, migrations, and D1 sync store for the web app |
packages/web | The Astro app on Cloudflare Workers: login, sync workflow, dashboards |
packages/cli | The bilan command, published as github-bilan |
Pushing a vX.Y.Z tag publishes the CLI to npm through the Publish workflow.
The tag must match the version in packages/cli/package.json and point to a
commit on main; a prerelease version is published under the next dist-tag.
A release is published through npm trusted publishing with provenance, or not
at all: no npm token is stored, the package's trusted publisher on npmjs.com
names the repository, the publish.yml workflow and the npm environment, and
the workflow refuses to publish from a private repository, where npm does not
accept provenance.
MIT.
TypeScript
83.0%
Astro
9.4%
CSS
6.5%
JavaScript
1.1%
The pulse of a GitHub repository. bilan turns pull request history into a picture of what is actually happening in a repository: who is contributing, which parts of the codebase are moving, how reviews flow and who carries them, and where work gets stuck.
See the codeThe pulse of a GitHub repository.
bilan turns pull request history into a picture of what is actually happening in a repository: who is contributing, which parts of the codebase are moving, how reviews flow and who carries them, and where work gets stuck. It is also a way to see what AI-assisted coding does to a codebase and a team over time: how much gets opened, how large the changes are, how fast they merge, how many carry a real review, and how review load lands on the humans.
Every latency is measured from the moment a pull request became ready for review, so time spent in draft is never charged to reviewers. bilan reads pull request metadata only, never code, and never writes to GitHub.
Two ways to use it:
npx github-bilan owner/name --open
The command syncs the repository into a local cache, writes
<name>.report.html, and opens it. Re-running is cheap: the sync is
incremental.
| Flag | Effect |
|---|---|
--open | Open the report in the browser when done |
--out FILE | Report path (default <name>.report.html) |
--since DATE | Reach further back than the default 30 days |
--full | Walk the entire history |
--max-prs N | Stop after N pull requests |
--no-cache | Ignore the local cache and fetch everything again |
--offline | Render from the cache without contacting GitHub; rejects sync options |
--token T | GitHub token; otherwise GITHUB_TOKEN, GH_TOKEN, then gh auth token |
--areas FILE | List the folders that are areas, nested ones included |
--no-cache builds a new cache beside the old one (<name>.json.fresh) and
only replaces it once the fresh sync finishes; a fresh sync stopped by the rate
limit, --max-prs or an interruption keeps the old cache, which the report then
shows, and running --no-cache again continues it. One fresh sync of a
repository runs at a time.
--offline only renders what is cached, so it rejects the sync options
--no-cache, --full, --since, --max-prs and --token; tokens in the
environment are ignored.
--areas replaces the inferred areas with a JSON file of folder paths from the
repository root, nested ones included:
{ "known": ["docs", "packages/app", "packages/integrations/node"] }. A changed
file counts toward the deepest listed folder holding it, other nested files
toward other, and files at the root toward root. Lockfiles, .changeset/
and release or dependency pull requests still count toward no area.
By default a sync covers the last 30 days of activity plus every open pull
request; later runs only widen that coverage. GitHub reports each request's
rate-limit cost, a few points per page of 25 pull requests; long review-request
histories need extra requests. The CLI reports points spent and stops near the
rate limit or at --max-prs. The next run first re-checks pull requests updated
since the stopped run began, then continues from where it stopped, so a history
too large for one rate-limit window finishes over several runs. The cache lives in ~/.cache/bilan, or $XDG_CACHE_HOME/bilan when set.
BILAN_CACHE_DIR can select a dedicated cache directory; empty values use the default.
An existing directory is only adopted when it is empty or holds a bilan cache
(older ones are recognized by their snapshots); anything else is rejected before
its permissions change. On Unix, cache directories
are owner-only (0700), and cache files and HTML exports are written with
owner-only permissions (0600), including when replacing older files.
The web app, at bilan.dev, runs on Cloudflare Workers
with D1, R2, KV, and Workflows. Signed out, it shows two built-in example
dashboards (withastro/astro and
cloudflare/workers-sdk) shipped as
static snapshots, plus the last sync of any public repository bilan already
holds, once the server token confirms it is still public; signing in is needed
to refresh it or sync more history. Private repositories always ask the viewer
to sign in, whether or not bilan holds them. Signed in, it syncs any repository you can read on your own
token into a shared cache, so a coworker who opens the same link sees the
dashboard instantly. A new repository shows a Start sync button; following
a link alone does not import its data. Existing dashboards can refresh during
navigation within the app. External links and bookmarks use the Refresh
button for stale data.
Access is checked per viewer against GitHub with the viewer's own token; allowed access and public visibility are cached for up to 15 minutes. Job progress requires repository access too, even for an old completed job. A private repository the viewer cannot read is indistinguishable from one that does not exist. Private data nobody has opened in 90 days is deleted.
Login is a GitHub App with read-only permissions (Pull requests, Metadata). User tokens expire after eight hours; bilan stores them encrypted and refreshes them, including mid-sync. Private repositories require the app to be installed on the organization, once, by an owner; members can request it from the app's install page. Public repositories need no install.
Installation callbacks without browser-bound OAuth state restart normal sign-in; their supplied authorization code is never used to create a session. Authenticated responses are not browser-cacheable, and dynamic responses disallow framing.
Sync admission is atomic in D1: one active job per repository, at most two
active jobs and 20 starts per account in a rolling 24 hours, and at most 20
active jobs and 200 starts globally. Deepening history still consumes these
budgets; failed attempts count toward the daily budget. A repeated sync of the
same depth has a 10-minute cooldown. The limits live in
packages/store-d1/src/jobs.ts. Abandoned reservations are reconciled before
admission, and a stalled live workflow must be terminated before its capacity
is released. If the engine cannot report its status, the reservation is kept.
Create a GitHub App with permissions Pull requests: Read-only and Metadata:
Read-only (and Members: Read-only under organization permissions, to resolve
team reviewers), "Request user authorization during installation" enabled,
expiring user tokens enabled, no webhook, and the callback URL of your
deployment plus http://localhost:8788/auth/github/callback for local use.
Put the app's slug in GITHUB_APP_SLUG in packages/web/wrangler.jsonc.
Create the Cloudflare resources and record their ids in wrangler.jsonc:
cd packages/web
npx wrangler d1 create bilan
npx wrangler r2 bucket create bilan-payloads
npx wrangler kv namespace create CACHE
Set the secrets and deploy:
npx wrangler secret put GITHUB_CLIENT_ID
npx wrangler secret put GITHUB_CLIENT_SECRET
openssl rand -base64 32 | npx wrangler secret put TOKEN_ENCRYPTION_KEY
npx wrangler d1 migrations apply bilan --remote
cd ../.. && pnpm run deploy
The deploy runs through turbo, which builds the dashboard and its
dependencies before wrangler deploy.
GITHUB_TOKEN, a classic token with no scopes, is optional: it is used as a
fallback for public repositories a user token cannot reach, and to confirm a
repository is still public before showing its dashboard to signed-out
viewers. Without it, a signed-out viewer only sees a public dashboard in the
15 minutes after a signed-in viewer's check confirmed it. A token GitHub
rejects (expired or revoked) is logged and treated as not configured.
Every push to main that passes CI is deployed by the Deploy workflow,
which applies pending D1 migrations and then runs wrangler deploy. It needs
two repository secrets: CLOUDFLARE_API_TOKEN (the "Edit Cloudflare Workers"
template plus D1 Edit) and CLOUDFLARE_ACCOUNT_ID.
The deploy gate verifies that the triggering run was a push to the canonical
repository's main, not a pull request or a fork's branch of the same name.
Cloudflare credentials are available only to the migration and deploy steps.
For local development, copy packages/web/.dev.vars.example to .dev.vars,
fill in the values, run npx wrangler d1 migrations apply bilan --local, then
pnpm --filter @bilan/web dev. Workflows require the Workers Paid plan.
The built-in examples are regenerated with pnpm examples:build, which needs
a GitHub token.
Each sync publishes gzip-compressed dashboard snapshots to R2 under
payload/<repo-id>/<sync-timestamp>.json.gz. The daily job at 06:00 UTC keeps
the snapshot referenced by D1's last_synced_at, regardless of age, and any
snapshot uploaded within the past 48 hours. It deletes older, superseded
snapshots only after verifying that the current snapshot exists in R2. If the
repository row, current reference, or current R2 object is missing, all older
snapshots are kept as possible recovery copies. Unknown key formats are also
left alone. The existing 90-day inactive private repository
cleanup still removes those repositories and all their snapshots.
To review the backlog from the repository root, authenticate with Wrangler
for the Cloudflare account configured in packages/web/wrangler.jsonc, then run:
pnpm --filter @bilan/web payloads:prune
This defaults to a dry run against production. It prints each candidate's
compressed size and key, then totals for objects scanned, candidate objects,
and reclaimable bytes. skippedUnverified counts old objects kept because no
current snapshot could be verified. It does not run the private repository/session cleanup.
To perform the deletions, re-evaluating candidates against D1:
pnpm --filter @bilan/web payloads:prune --apply
Both paths process up to 1,000 objects at a time and re-read each repository's
current reference before deleting its batch. Errors stop the run; a later run
can safely resume by scanning the remaining objects. Use --local to work with
local Wrangler state instead of production, or --help for the command options.
| Metric | Window anchor | Definition |
|---|---|---|
| PRs opened | created | |
| Merged / closed unmerged | merged / closed | |
| Merged PRs by area | merged | per area and week, or month over all time; counted in every area touched |
| Time in draft | created | opened → marked ready (draft-opened pull requests only) |
| Time to first review | ready | ready → first review by someone other than the author |
| Time to merge | merged | ready → merged |
| Reviewer turnaround | review | review request (or ready, if never requested) → that reviewer's first review |
| Reviews given | review | self-reviews excluded |
| Review threads per PR | merged | review threads ÷ pull requests merged, by size |
| Approved with no threads | merged | every review was an approval and no review thread was opened |
| Time to approval | merged | ready → first approval, by size |
Areas are folders, read from up to 30 changed file paths per pull request; a
pull request touching two areas counts in both. Each top-level folder is an
area, except that a folder touched by more than half the pull requests, such as
packages/ in a monorepo, is split into its packages: the outermost folders,
at most three deep, holding a sampled package.json, Cargo.toml, go.mod or
pyproject.toml, or its subfolders when it holds none. A folder stays whole
when one piece would still hold more than 90% of its pull requests. Lockfiles
and .changeset/ count toward no area, and neither does a pull request whose
sampled files are only those, manifests and changelogs, as release and
dependency pull requests are. Files at the repository root count as root.
A pull request that opened as a draft becomes reviewable at its first ready-for-review event; later re-drafts are ignored. Each metric is scoped by its own anchor, so "last 30 days" means what happened in those 30 days rather than which pull requests were opened in them. Up to 40 reviews are captured per pull request.
Review depth groups merged pull requests by size, in lines added + deleted: ≤50, 51–250, 251–500, 501–1k, and over 1k. Its thread count is GitHub's total per pull request, which does not say who opened each thread, so threads opened by bot reviewers (such as Copilot) count too and overstate human discussion.
Draft transitions are fetched separately from review requests, and review-request histories are paginated without a fixed event limit. Run --full to refresh all cached PR metadata after upgrading.
pnpm install
pnpm check # oxfmt, oxlint, tsc, vitest across the workspace
pnpm build # bundles the dashboard, the CLI, and the web app
node packages/cli/bin/bilan.mjs owner/name --open
| Package | Role |
|---|---|
packages/core | GitHub GraphQL client, sync engine, derive step, and metrics; Web APIs only |
packages/ui | The dashboard as a framework-free module |
packages/store-file | JSON-file sync store used by the CLI |
packages/store-d1 | Drizzle schema, migrations, and D1 sync store for the web app |
packages/web | The Astro app on Cloudflare Workers: login, sync workflow, dashboards |
packages/cli | The bilan command, published as github-bilan |
Pushing a vX.Y.Z tag publishes the CLI to npm through the Publish workflow.
The tag must match the version in packages/cli/package.json and point to a
commit on main; a prerelease version is published under the next dist-tag.
A release is published through npm trusted publishing with provenance, or not
at all: no npm token is stored, the package's trusted publisher on npmjs.com
names the repository, the publish.yml workflow and the npm environment, and
the workflow refuses to publish from a private repository, where npm does not
accept provenance.
MIT.
TypeScript
83.0%
Astro
9.4%
CSS
6.5%
JavaScript
1.1%