Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/MetricBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function MetricBar() {

let modes: LeaderMode[] | null = null;
if (routeId === "/") {
// The company board (?kind=org) ranks by commits only — no metric to pick yet.
// The organization board (?kind=org) ranks by commits only — no metric to pick yet.
modes = boardKind === "org" ? null : LEADER_MODES;
} else if (routeId === "/$user") {
if (lookup?.kind === "org") {
Expand Down
27 changes: 24 additions & 3 deletions src/components/OrgView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { OrgMemberEntry, OrgResult } from "#/lib/org";
import type { OrgSummary } from "#/lib/org-cache";

/**
* Org ("company") views rendered by the /$user route when a login resolves to an organization
* Organization views rendered by the /$user route when a login resolves to an organization
* (GitHub logins share one namespace, so /paritytech IS the org page). Header + lifetime totals
* of the members' contributions *to this org*, plus the building/error states. No chart yet:
* orgs have no monthly data until the background worker lands (issue #84 follow-up).
Expand Down Expand Up @@ -153,6 +153,27 @@ export function OrgResultView({
);
}

// Valid but too large to build on demand — recorded and queued for the background worker.
// A gentle notice, not a failure: no Retry (retrying can't help), just "check back later".
if (result.indexing) {
return (
<main className="mx-auto max-w-md px-6 py-24 text-center">
<h1 className="text-xl font-semibold">
We’re still indexing {result.login}
</h1>
<p className="mt-3 text-sm text-muted-foreground">{result.indexing}</p>
<div className="mt-6">
<Link
to="/"
className="text-sm text-muted-foreground hover:underline"
>
← Try another lookup
</Link>
</div>
</main>
);
}

// Hard failure, or polling gave up on a stalled build.
const message = result.error
? result.error
Expand Down Expand Up @@ -247,7 +268,7 @@ function LoadedOrg({
repositories, as attributed by GitHub. Private members and private
contributions aren’t included.{" "}
<Link
to="/company/$slug"
to="/organizations/$slug"
params={{ slug: "stats" }}
className="whitespace-nowrap underline decoration-dotted underline-offset-2 hover:text-foreground"
>
Expand Down Expand Up @@ -285,7 +306,7 @@ function MemberBoard({
Public members ranked by their lifetime commits to {org.login}’s
repositories.{" "}
<Link
to="/company/$slug"
to="/organizations/$slug"
params={{ slug: "members" }}
className="whitespace-nowrap underline decoration-dotted underline-offset-2 hover:text-foreground"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ updatedAt: "2026-07-08"
order: 2
---

The member leaderboard on a company page lists the organization's **public
The member leaderboard on an organization page lists the organization's **public
members** — and that's almost always the answer to "why am I not on it":
**GitHub makes organization membership private by default.** Unless you've
explicitly published yours, nobody outside the organization (including us) can
Expand All @@ -21,24 +21,24 @@ see that you're a member at all.

GitHub only lets *you* do this for yourself — an org admin can't publish your
membership for you. Once it's public, you'll appear the next time the
company's data is refreshed, and your contributions start counting toward the
[company totals](/company/stats) too.
organization's data is refreshed, and your contributions start counting toward
the [organization totals](/organizations/stats) too.

## Other reasons someone is missing

- **They're not a member.** Contributing to an org's repositories — even a
lot — doesn't make someone a member. Outside contributors don't appear on
the member list and don't count toward the totals.
- **The list hasn't refreshed yet.** Membership is captured when the company
is first looked up and re-synced periodically, so a freshly-publicized
membership can take a while to show up.
- **The list hasn't refreshed yet.** Membership is captured when the
organization is first looked up and re-synced periodically, so a
freshly-publicized membership can take a while to show up.
- **The account is suspended on commit-history.** Profiles under review are
hidden from every leaderboard, member lists included.

## Why someone shows zero

Being on the list with a **0** is normal and not a bug. The numbers are
contributions **to this organization's public repositories** only — see
[how company stats are calculated](/company/stats). A member shows zero when
their work in the org happens in private repositories, under an email not
linked to their GitHub account, or simply in other places entirely.
[how organization stats are calculated](/organizations/stats). A member shows
zero when their work in the org happens in private repositories, under an email
not linked to their GitHub account, or simply in other places entirely.
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
title: "How company stats are calculated"
description: "Where a company's commits, pull requests, reviews and issues come from: each public member's contributions to that organization's repositories, summed — and what that number deliberately leaves out."
title: "How organization stats are calculated"
description: "Where an organization's commits, pull requests, reviews and issues come from: each public member's contributions to that organization's repositories, summed — and what that number deliberately leaves out."
publishedAt: "2026-07-08"
updatedAt: "2026-07-08"
order: 1
---

A company page answers a narrower question than it might look like: **how much
have this organization's public members publicly contributed to the
An organization page answers a narrower question than it might look like: **how
much have this organization's public members publicly contributed to the
organization's own repositories?** Every number on the page — the headline
stats, the member leaderboard, the company's position on the company
stats, the member leaderboard, the organization's position on the organization
leaderboard — is built from that one measure.

## The calculation
Expand All @@ -24,8 +24,8 @@ Within that scope, GitHub's usual contribution rules apply, the same ones your
personal profile uses: commits count on the default branch, under an email
linked to the account, in non-fork repositories.

The company totals are simply those per-member numbers **summed**. The member
leaderboard shows the same rows the sum is made of, so the two always
The organization totals are simply those per-member numbers **summed**. The
member leaderboard shows the same rows the sum is made of, so the two always
reconcile.

## What the number is *not*
Expand All @@ -35,32 +35,33 @@ things are excluded, and they can make the totals smaller than you'd expect:

- **Private members.** GitHub keeps organization membership private by
default, and we can only see members who made theirs public. An active
company whose people all keep membership private shows a total of zero —
see [why am I not on this member list?](/company/members).
organization whose people all keep membership private shows a total of zero —
see [why am I not on this member list?](/organizations/members).
- **Outside contributors.** Someone who sends pull requests to the org's
repos without being a member isn't counted. The measure is about the
company's people, not its repos' traffic.
organization's people, not its repos' traffic.
- **Private repositories.** We only see public activity. Work in the org's
private repos is invisible to us, even for public members.

So treat the company numbers as the org's **public footprint**: what its
So treat the organization numbers as the org's **public footprint**: what its
publicly-listed people have visibly built there.

## How this differs from a personal profile

Your personal page counts your public contributions **across all of GitHub**.
A company page counts only what landed **in that org's repositories**. That's
why your number on a member leaderboard is always less than or equal to the
number on your own profile — often much less, if most of your work happens
An organization page counts only what landed **in that org's repositories**.
That's why your number on a member leaderboard is always less than or equal to
the number on your own profile — often much less, if most of your work happens
elsewhere.

## Freshness and quirks

- Totals cover every **completed month** up to now; the current in-progress
month rolls in once it ends (the same convention as personal profiles).
- A company's numbers are collected when it's first looked up and refreshed
periodically after that, so very recent activity can take a while to appear.
- Company pages don't have a commit chart yet — we currently store lifetime
- An organization's numbers are collected when it's first looked up and
refreshed periodically after that, so very recent activity can take a while
to appear.
- Organization pages don't have a commit chart yet — we currently store lifetime
totals per member, and the per-month history that powers the chart is
collected in a slower background pass.
- In rare cases GitHub's API cannot serve a specific slice of a member's
Expand Down
2 changes: 1 addition & 1 deletion src/lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ async function persistEntity(
}
}

async function recordLookup(database: DB, id: string, now: Date) {
export async function recordLookup(database: DB, id: string, now: Date) {
try {
await database.insert(lookups).values({ entityId: id, searchedAt: now });
} catch {
Expand Down
19 changes: 16 additions & 3 deletions src/lib/commit-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export interface RecentEntry {
login: string;
name: string | null;
avatarUrl: string | null;
/** Drives the chip's avatar shape (org = square, user = circle) and the verified badge. */
kind: "user" | "org";
/** Org-only verified badge; null for users. */
isVerified: boolean | null;
}
export interface StartPageData {
recent: RecentEntry[];
Expand Down Expand Up @@ -220,20 +224,29 @@ async function queryRecent(limit: number): Promise<RecentEntry[]> {
login: entities.login,
name: entities.name,
avatarUrl: entities.avatarUrl,
kind: entities.kind,
isVerified: entities.isVerified,
last: sql<string>`max(${lookups.searchedAt})`,
})
.from(lookups)
.innerJoin(entities, eq(entities.id, lookups.entityId))
// kind filter is defensive — org lookups aren't recorded (v1), but the strip links every
// entry to /$user, which for an org login would kick off a bogus user build.
.where(and(isNull(entities.suspendedAt), eq(entities.kind, "user")))
// Users and orgs both belong in the strip (each links to /$user, which resolves either);
// repos are the only other kind and don't get a page here, so they're excluded.
.where(
and(
isNull(entities.suspendedAt),
inArray(entities.kind, ["user", "org"]),
),
)
.groupBy(entities.id)
.orderBy(desc(sql`max(${lookups.searchedAt})`))
.limit(limit);
return rows.map((r) => ({
login: r.login,
name: r.name,
avatarUrl: r.avatarUrl,
kind: r.kind === "org" ? "org" : "user",
isVerified: r.isVerified,
}));
}

Expand Down
32 changes: 15 additions & 17 deletions src/lib/content.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* The MDX content collections:
* - src/content/metrics/<slug>.mdx → /metrics/<slug> (individual metrics, /metrics/explained hub)
* - src/content/company/<slug>.mdx → /company/<slug> (company/org context — deliberately its own
* collection, NOT mixed into the individuals' hub: the definitions differ, e.g. org-scoped vs
* global contributions)
* - src/content/organizations/<slug>.mdx → /organizations/<slug> (organization context —
* deliberately its own collection, NOT mixed into the individuals' hub: the definitions differ,
* e.g. org-scoped vs global contributions)
*
* Two globs per collection with different costs:
* - an eager, frontmatter-only glob (tiny — just the exported metadata objects), used
Expand Down Expand Up @@ -69,36 +69,34 @@ export function loadArticle(
return load?.();
}

// ── Company collection (/company/<slug>) ─────────────────────────────────────
// ── Organization collection (/organizations/<slug>) ──────────────────────────

const companyFrontmatters = import.meta.glob<ArticleFrontmatter>(
"../content/company/*.mdx",
const orgFrontmatters = import.meta.glob<ArticleFrontmatter>(
"../content/organizations/*.mdx",
{ eager: true, import: "frontmatter" },
);

const companyComponents = import.meta.glob<{ default: MDXContent }>(
"../content/company/*.mdx",
const orgComponents = import.meta.glob<{ default: MDXContent }>(
"../content/organizations/*.mdx",
);

/** All company articles in curated reading order. */
export const companyArticles: ArticleMeta[] = Object.entries(
companyFrontmatters,
)
/** All organization articles in curated reading order. */
export const orgArticles: ArticleMeta[] = Object.entries(orgFrontmatters)
.map(([path, fm]) => ({ slug: slugOf(path), ...fm }))
.sort(
(a, b) =>
(a.order ?? Number.MAX_SAFE_INTEGER) -
(b.order ?? Number.MAX_SAFE_INTEGER) || a.title.localeCompare(b.title),
);

export function getCompanyArticleMeta(slug: string): ArticleMeta | undefined {
return companyArticles.find((a) => a.slug === slug);
export function getOrgArticleMeta(slug: string): ArticleMeta | undefined {
return orgArticles.find((a) => a.slug === slug);
}

/** Lazily import a company article's compiled MDX module (shape fits React.lazy). */
export function loadCompanyArticle(
/** Lazily import an organization article's compiled MDX module (shape fits React.lazy). */
export function loadOrgArticle(
slug: string,
): Promise<{ default: MDXContent }> | undefined {
const load = companyComponents[`../content/company/${slug}.mdx`];
const load = orgComponents[`../content/organizations/${slug}.mdx`];
return load?.();
}
36 changes: 27 additions & 9 deletions src/lib/org-cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { and, eq, isNull, sql } from "drizzle-orm";
import { recordLookup } from "#/lib/cache";
import { type DB, db } from "#/lib/db";
import { entities, orgMembers } from "#/lib/db/schema";
import {
Expand All @@ -12,12 +13,13 @@ import {
} from "#/lib/github";

/**
* Incremental org ("company") cache — the org sibling of cache.ts.
* Incremental organization cache — the org sibling of cache.ts.
*
* An org's numbers are the sum of its members' contributions *to that org* (org-scoped
* `contributionsCollection(organizationID: …)`, a different number from each member's global
* totals). Those per-member sums live in `org_members`; the roll-up lands on the org's
* `entities` row so the company leaderboard ranks orgs exactly like the user board ranks users.
* `entities` row so the organization leaderboard ranks orgs exactly like the user board ranks
* users.
*
* Builds are **resumable** at member granularity: enumeration inserts every member as a pending
* `org_members` row (`lastFetched` null), each fetched member is stamped the moment its totals
Expand All @@ -43,9 +45,10 @@ const BUILD_BUDGET_MS = 6_000;
const MEMBER_CONCURRENCY = 3;

// Orgs with more visible members than this get a clean refusal instead of an on-demand build:
// at ~2 requests per member a mega-org lookup would eat the shared token's hourly budget and
// poison it for every visitor. Lifting this needs the background worker.
const MAX_ORG_MEMBERS = 400;
// at ~2 requests per member even a mid-size org lookup can eat into the shared token's hourly
// budget and poison it for every visitor. We keep this deliberately low and record the refused
// orgs (see getFromDb) so the background worker can backfill them later, off the request path.
const MAX_ORG_MEMBERS = 25;

export interface OrgSummary {
login: string;
Expand Down Expand Up @@ -141,11 +144,19 @@ async function getFromDb(

if (!row) {
// First sighting: the profile fetch validates the login and yields the node id (keys every
// org-scoped contribution query) + createdAt (caps member windows). Refuse oversized orgs
// BEFORE writing anything — a half-enumerated mega-org would burn quota on every poll.
// org-scoped contribution query) + createdAt (caps member windows). Record the profile
// (a single cheap upsert into `entities`, builtAt still null) BEFORE refusing an oversized
// org — this leaves a tracked row the background worker can later backfill, instead of
// discarding the lookup. The request path still won't build it live.
const profile = await fetchOrgProfile(login, token);
assertBuildable(profile);
row = await upsertOrgProfile(database, id, profile, now);
// Record the lookup before the size check so even an oversized org we refuse to build
// still surfaces in "recently looked up" — the row exists and the strip links to /$user.
await recordLookup(database, id, now);
assertBuildable(profile);
} else {
// Known org, revisited: bump its recency so it re-sorts to the front of the strip.
await recordLookup(database, id, now);
}

const complete = row.builtAt != null;
Expand Down Expand Up @@ -183,6 +194,13 @@ async function getFromDb(
throw new GitHubError(`Could not resolve "${login}" on GitHub.`, 502);
}

// Enrolled but oversized: the row is recorded for the worker, but the request path must refuse
// it — cheaply, from the stored memberCount, so a repeat lookup never pays to re-enumerate its
// members before bailing. (assertBuildable already caught it at first sighting.)
if ((row.memberCount ?? 0) > MAX_ORG_MEMBERS) {
throw new GitHubError(tooLargeMessage(login, row.memberCount ?? 0), 422);
}

// Enumerate members once per build: every member becomes a pending org_members row, which
// doubles as the resume marker. Re-enumeration (membership drift) is the worker's job.
if ((await memberRowCount(database, id)) === 0) {
Expand Down Expand Up @@ -278,7 +296,7 @@ function assertBuildable(profile: OrgProfile) {
}

function tooLargeMessage(login: string, count: number) {
return `"${login}" has ${count.toLocaleString()} public members — too many for an on-demand build yet.`;
return `${login} has ${count.toLocaleString()} public members — a bit much for us to crunch on the spot. We’ve added it to the queue and are indexing it in the background; check back a little later. Thanks for your interest!`;
}

async function memberRowCount(database: DB, id: string): Promise<number> {
Expand Down
Loading