charpeni/bilan

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.

TypeScript

0

112 commits

updated Oct 1, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a tool that analyzes GitHub repos and help you understand the impact of AI (r/github)

We keep hearing that pull requests are the new bottleneck, but it’s hard to visualize, the list is forever growing because code is becoming cheaper, but it doesn’t compare easily. Some people will squash, others use merge commits, so we can’t just use the number of commits, the number of pull…

0

Oct 1, 2026

README

bilan

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. 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:

  • CLI: sync a repository with your own token and get a self-contained HTML report you can open or share as a file.
  • Web app: sign in with GitHub on bilan.dev, open any repository you can read, and share the dashboard link with coworkers who can read it too.

CLI

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.

FlagEffect
--openOpen the report in the browser when done
--out FILEReport path (default <name>.report.html)
--since DATEReach further back than the default 30 days
--fullWalk the entire history
--max-prs NStop after N pull requests
--no-cacheIgnore the local cache and fetch everything again
--offlineRender from the cache without contacting GitHub; rejects sync options
--token TGitHub token; otherwise GITHUB_TOKEN, GH_TOKEN, then gh auth token
--areas FILEList 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.

Web app

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.

Running it yourself

  1. 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.

  2. Put the app's slug in GITHUB_APP_SLUG in packages/web/wrangler.jsonc.

  3. 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
    
  4. 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.

Payload storage maintenance

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.

What the numbers mean

MetricWindow anchorDefinition
PRs openedcreated
Merged / closed unmergedmerged / closed
Merged PRs by areamergedper area and week, or month over all time; counted in every area touched
Time in draftcreatedopened → marked ready (draft-opened pull requests only)
Time to first reviewreadyready → first review by someone other than the author
Time to mergemergedready → merged
Reviewer turnaroundreviewreview request (or ready, if never requested) → that reviewer's first review
Reviews givenreviewself-reviews excluded
Review threads per PRmergedreview threads ÷ pull requests merged, by size
Approved with no threadsmergedevery review was an approval and no review thread was opened
Time to approvalmergedready → 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.

Development

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
PackageRole
packages/coreGitHub GraphQL client, sync engine, derive step, and metrics; Web APIs only
packages/uiThe dashboard as a framework-free module
packages/store-fileJSON-file sync store used by the CLI
packages/store-d1Drizzle schema, migrations, and D1 sync store for the web app
packages/webThe Astro app on Cloudflare Workers: login, sync workflow, dashboards
packages/cliThe 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.

License

MIT.

charpeni/bilan

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.

TypeScript

0

112 commits

updated Oct 1, 2026

See the code

See what people are saying

SourceMessageScoreDate

I built a tool that analyzes GitHub repos and help you understand the impact of AI (r/github)

We keep hearing that pull requests are the new bottleneck, but it’s hard to visualize, the list is forever growing because code is becoming cheaper, but it doesn’t compare easily. Some people will squash, others use merge commits, so we can’t just use the number of commits, the number of pull…

0

Oct 1, 2026

README

bilan

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. 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:

  • CLI: sync a repository with your own token and get a self-contained HTML report you can open or share as a file.
  • Web app: sign in with GitHub on bilan.dev, open any repository you can read, and share the dashboard link with coworkers who can read it too.

CLI

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.

FlagEffect
--openOpen the report in the browser when done
--out FILEReport path (default <name>.report.html)
--since DATEReach further back than the default 30 days
--fullWalk the entire history
--max-prs NStop after N pull requests
--no-cacheIgnore the local cache and fetch everything again
--offlineRender from the cache without contacting GitHub; rejects sync options
--token TGitHub token; otherwise GITHUB_TOKEN, GH_TOKEN, then gh auth token
--areas FILEList 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.

Web app

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.

Running it yourself

  1. 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.

  2. Put the app's slug in GITHUB_APP_SLUG in packages/web/wrangler.jsonc.

  3. 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
    
  4. 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.

Payload storage maintenance

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.

What the numbers mean

MetricWindow anchorDefinition
PRs openedcreated
Merged / closed unmergedmerged / closed
Merged PRs by areamergedper area and week, or month over all time; counted in every area touched
Time in draftcreatedopened → marked ready (draft-opened pull requests only)
Time to first reviewreadyready → first review by someone other than the author
Time to mergemergedready → merged
Reviewer turnaroundreviewreview request (or ready, if never requested) → that reviewer's first review
Reviews givenreviewself-reviews excluded
Review threads per PRmergedreview threads ÷ pull requests merged, by size
Approved with no threadsmergedevery review was an approval and no review thread was opened
Time to approvalmergedready → 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.

Development

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
PackageRole
packages/coreGitHub GraphQL client, sync engine, derive step, and metrics; Web APIs only
packages/uiThe dashboard as a framework-free module
packages/store-fileJSON-file sync store used by the CLI
packages/store-d1Drizzle schema, migrations, and D1 sync store for the web app
packages/webThe Astro app on Cloudflare Workers: login, sync workflow, dashboards
packages/cliThe 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.

License

MIT.

Languages

TypeScript

83.0%

Astro

9.4%

CSS

6.5%

JavaScript

1.1%