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
130 changes: 130 additions & 0 deletions .github/scripts/generateLeaderboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
module.exports = async ({ github, context }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;

const EXCLUDED = new Set([
'shantkhatri',
'harxhit',
'blankirigaya'
]);

const contributors = new Map();

const ensure = (login, avatarUrl, profileUrl) => {
if (!contributors.has(login)) {
contributors.set(login, {
login,
avatarUrl,
profileUrl,
mergedPrs: 0,
openPrs: 0,
issues: 0
});
}

return contributors.get(login);
};

const mergedPrs = await github.paginate(
github.rest.pulls.list,
{
owner,
repo,
state: 'closed',
per_page: 100
}
);

for (const pr of mergedPrs) {
if (!pr.merged_at || !pr.user) continue;

const login = pr.user.login;

if (EXCLUDED.has(login.toLowerCase())) continue;

const user = ensure(
login,
pr.user.avatar_url,
pr.user.html_url
);

user.mergedPrs++;
}

const openPrs = await github.paginate(
github.rest.pulls.list,
{
owner,
repo,
state: 'open',
per_page: 100
}
);

for (const pr of openPrs) {
if (!pr.user) continue;

const login = pr.user.login;

if (EXCLUDED.has(login.toLowerCase())) continue;

const user = ensure(
login,
pr.user.avatar_url,
pr.user.html_url
);

user.openPrs++;
}

const issues = await github.paginate(
github.rest.issues.listForRepo,
{
owner,
repo,
state: 'all',
per_page: 100
}
);

for (const issue of issues) {
if (issue.pull_request || !issue.user) continue;

const login = issue.user.login;

if (EXCLUDED.has(login.toLowerCase())) continue;

const user = ensure(
login,
issue.user.avatar_url,
issue.user.html_url
);

user.issues++;
}

const leaderboard = [...contributors.values()].sort(
(a, b) =>
b.mergedPrs - a.mergedPrs ||
b.issues - a.issues ||
b.openPrs - a.openPrs ||
a.login.localeCompare(b.login)
);

const fs = require('fs');
const path = require('path');

const outputDir = path.join('apps', 'web', 'public');
const outputFile = path.join(outputDir, 'leaderboard.json');

fs.mkdirSync(outputDir, { recursive: true });

fs.writeFileSync(
outputFile,
JSON.stringify(leaderboard, null, 2),
'utf8'
);

console.log(`Generated ${leaderboard.length} contributors`);
console.log(`Leaderboard written to ${outputFile}`);
};
48 changes: 48 additions & 0 deletions .github/workflows/leaderboard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Update Leaderboard

on:
workflow_dispatch:
schedule:
- cron: '0 * * * *'

permissions:
contents: write
pull-requests: read
issues: read

jobs:
leaderboard:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22

- name: Generate leaderboard
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const script = require('./.github/scripts/generateLeaderboard.js');
await script({ github, context });

- name: Commit leaderboard
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git add public/leaderboard.json

if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi

git commit -m "chore: update leaderboard"
git push

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ Thanks to all the amazing people who contribute to **DevCard** 🚀
</a>
</p>

> 🏆 **Contributor Leaderboard** — contributors are also ranked by merged PRs, issues, and open PRs in the web app at the [`/leaderboard`](https://devcard.app/leaderboard) route (`apps/web`).

<br>

## Project Support
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom';
import LandingPage from './pages/LandingPage';
import ProfilePage from './pages/ProfilePage';
import CardPage from './pages/CardPage';
import LeaderboardPage from './pages/LeaderboardPage';
import NotFound from './pages/NotFound';

export default function App() {
Expand All @@ -10,6 +11,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/u/:username" element={<ProfilePage />} />
<Route path="/devcard/:id" element={<CardPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/pages/LeaderboardPage.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
.leaderboard {
max-width: 860px;
margin: 0 auto;
padding: 7rem 1.5rem 4rem;
}

.leaderboard-header {
text-align: center;
margin-bottom: 3rem;
}

.leaderboard-header h1 {
font-size: clamp(2.2rem, 5vw, 3.2rem);
font-weight: 900;
font-family: 'Outfit', sans-serif;
margin: 1rem 0 0.75rem;
}

.leaderboard-subtitle {
color: var(--text-secondary);
line-height: 1.7;
max-width: 520px;
margin: 0 auto;
}

.leaderboard-state {
text-align: center;
padding: 2.5rem 1.5rem;
border-radius: var(--radius-lg);
color: var(--text-secondary);
}

.leaderboard-state.error {
color: #ef4444;
}

.leaderboard-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0;
margin: 0;
}

.leaderboard-row {
display: grid;
grid-template-columns: 2.5rem 1fr auto;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-radius: var(--radius-lg);
}

.rank {
font-weight: 800;
font-size: 1.1rem;
text-align: center;
color: var(--text-muted);
}

.rank-1 { color: #f5c518; }
.rank-2 { color: #c0c5ce; }
.rank-3 { color: #cd7f32; }

.contributor {
display: flex;
align-items: center;
gap: 0.85rem;
text-decoration: none;
color: var(--text-primary);
min-width: 0;
}

.contributor-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid var(--border-glass);
flex-shrink: 0;
}

.contributor-name {
font-weight: 600;
font-family: 'Outfit', sans-serif;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.contributor:hover .contributor-name {
color: var(--primary);
}

.stats {
display: flex;
gap: 1.25rem;
}

.stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.5rem;
}

.stat-value {
font-weight: 800;
font-size: 1.15rem;
color: var(--text-primary);
}

.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}

.leaderboard-footer {
text-align: center;
margin-top: 2.5rem;
}

.leaderboard-footer a {
text-decoration: none;
font-weight: 600;
}

@media (max-width: 600px) {
.leaderboard-row {
grid-template-columns: 2rem 1fr;
grid-template-areas:
'rank contributor'
'stats stats';
row-gap: 0.85rem;
}
.rank { grid-area: rank; }
.contributor { grid-area: contributor; }
.stats {
grid-area: stats;
justify-content: space-around;
width: 100%;
}
}
Loading