A GitHub profile is hard to skim. A recruiter opens it, sees a grid of green squares and a list of repos, and has maybe ten seconds to decide if it's worth a closer look. I wanted to turn public GitHub work into one number that could be sorted and filtered by language and country.
This post walks through the formula I ended up with, the GitHub API calls behind it, and a few things the math taught me that I didn't expect.
What goes into the score
Five signals, all from the public GitHub API:
- Public repos you own, forks excluded. Forking 50 repos shouldn't count as shipping 50 projects.
- Total stars on those repos.
- Followers.
- Public events in the last 90 days. Pushes, PRs, issues, reviews and so on.
- Account age.
Here is the actual scoring function:
export function scoreParts(p) {
const repos = Math.log1p(p.publicRepos) * 30;
const stars = Math.log1p(p.stars) * 35;
const followers = Math.log1p(p.followers) * 15;
const activity = Math.min(p.recentEvents / 30, 1) * 15;
const age = Math.min(p.accountAgeDays / 3650, 1) * 5;
return { repos, stars, followers, activity, age };
}
The total is the sum, rounded to one decimal place.
Why log scaling
Stars on GitHub follow a power law. A handful of repos have hundreds of thousands, and most have zero to ten. If you add raw counts, one viral repo makes every other signal pointless.
Math.log1p(x) is ln(1 + x). The +1 matters, because it means zero stars gives zero points instead of negative infinity. Here is what stars are worth with a weight of 35:
| Stars | Points |
|---|---|
| 10 | 83.9 |
| 100 | 161.5 |
| 1,000 | 241.8 |
| 10,000 | 322.4 |
Every 10x more stars adds roughly the same ~80 points. Going from 10 to 100 stars is worth as much as going from 1,000 to 10,000. That felt fair to me: getting your first 100 stars is a real achievement, and it should show.
Why activity and age are capped
Activity is min(events / 30, 1). Thirty public events in 90 days is a full score, and more than that adds nothing. Without a cap, the score turns into a commit-streak game where people push empty commits to climb. I didn't want that.
Account age caps at 10 years (3,650 days) and is only worth 5 points. It's a small tie-breaker, not a reward for having signed up in 2012.
Lesson 1: the weights are not percentages
On the site I describe the mix as "repos 30%, stars 35%, followers 15%, activity 15%, age 5%". That's what the multipliers say. But the log parts have no upper limit and the capped parts do, so in real profiles the log signals take up much more than their label suggests.
Two made-up profiles, run through the same function:
| Dev A | Dev B | |
|---|---|---|
| Own repos | 12 | 1 |
| Stars | 40 | 5,000 |
| Followers | 25 | 300 |
| Events (90 days) | 30 | 3 |
| Account age | 5 years | 2 years |
| Score | 273.3 | 407.0 |
Dev A ships steadily and gets the full activity score. Dev B has one hit repo. Dev B has 125x the stars, but gets only about 2.3x the star points (298.1 vs 130.0). Log scaling did its job of shrinking the gap. Dev B still wins, though, because stars and followers aren't capped.
I kept it this way on purpose. A repo with 5,000 stars is a strong hiring signal. But it's worth knowing that "weight" and "share of the final score" are not the same thing. So every profile shows a breakdown bar with each signal's real share of that person's score, instead of only the labels.
Lesson 2: the API shapes the formula
A few details came from GitHub's API, not from my own design choices:
-
Repos:
GET /users/{user}/repos?per_page=100&type=owner, paged up to 5 pages, then forks are filtered out. Stars are summed from that list. -
Events:
GET /users/{user}/events/public?per_page=100, then filtered to the last 90 days. GitHub only keeps recent public events here, so a longer window wouldn't be reliable anyway. -
Language: there's no "main language" field on a user. I count the
languagefield on each owned repo and keep the top four. -
Country: the
locationfield is free text written by the developer. "Bengaluru", "Bangalore" and "India" all need to map to the same place, so it's a keyword list per country, matched on word boundaries. It's simple and it misses things, but it never guesses a location the person didn't write.
GitHub responses are cached for about 8 hours, which also keeps the app well inside the API rate limits.
Lesson 3: rank repos, not just people
Ranking people only works once people show up. Ranking repos works on day one, because the GitHub search API already has the data:
language:Python stars:>200 pushed:>{30 days ago} archived:false
sorted by stars, 25 per page. The pushed:> filter matters. Without it, the board fills up with famous but abandoned projects.
On today's Python board (October 26, 2026), the top three are public-apis/public-apis (492k stars), EbookFoundation/free-programming-books (400k) and vinta/awesome-python (330k). The 25 repos have about 5.0M stars combined, and MIT is the most common license (11 of 25). What surprised me is that the top of a "most-starred" board is mostly lists and learning resources, not libraries. If you want to find tools to use, a second board for repos created in the last 90 days is much more useful.
What I'd tell anyone building a ranking
- Log-scale anything that follows a power law.
- Cap anything people can farm.
- Show the breakdown so people can see why they rank where they do.
- Read the API docs before you design the formula, because the data you can actually get decides more than you think.
The project is topme.dev. It's free, you can add a GitHub username without logging in, verified owners get a badge, and owners can take their profile off at /remove. The repo boards are at topme.dev/repos. If you think the formula is wrong somewhere, I'd like to hear where.
Top comments (0)