mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2026-09-17 09:53:21 +07:00
feat: add leaderboard category
This commit is contained in:
+2
@@ -9,6 +9,8 @@ CREATE TABLE `repositories` (
|
||||
`last_update` text NOT NULL,
|
||||
`languages` text,
|
||||
`contributors` text,
|
||||
`is_pending` integer DEFAULT false NOT NULL,
|
||||
`is_error` integer DEFAULT false NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "891041fb-3c81-46b0-ac5a-cd85a640fe8c",
|
||||
"id": "13d859a6-4c32-46a6-90e9-19740b2bb13a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"repositories": {
|
||||
@@ -77,6 +77,22 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_pending": {
|
||||
"name": "is_pending",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1723077872529,
|
||||
"tag": "0000_medical_magma",
|
||||
"when": 1723210265266,
|
||||
"tag": "0000_young_emma_frost",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
+3
-6
@@ -3,7 +3,6 @@ import db from ".";
|
||||
import logger from "../lib/logger";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import queue from "@server/lib/queue";
|
||||
|
||||
const seed = async () => {
|
||||
logger.info("🌿 Seeding database...");
|
||||
@@ -11,9 +10,9 @@ const seed = async () => {
|
||||
await db.transaction(async (tx) => {
|
||||
tx.run(sql`DELETE FROM users`);
|
||||
|
||||
await tx
|
||||
.insert(users)
|
||||
.values({ username: "khairul169", name: "Khairul Hidayat" });
|
||||
// await tx
|
||||
// .insert(users)
|
||||
// .values({ username: "khairul169", name: "Khairul Hidayat" });
|
||||
|
||||
await tx.insert(users).values(
|
||||
[...Array(50)].map(() => ({
|
||||
@@ -29,8 +28,6 @@ const seed = async () => {
|
||||
);
|
||||
});
|
||||
|
||||
await queue.add("fetchUserProfile", { userId: 1 });
|
||||
|
||||
logger.info("🌱 Database seeded");
|
||||
|
||||
process.exit();
|
||||
|
||||
@@ -34,7 +34,7 @@ export const fetchRepoContributors = async (
|
||||
|
||||
const [result] = await db
|
||||
.update(repositories)
|
||||
.set({ contributors })
|
||||
.set({ contributors, isPending: false, isError: false })
|
||||
.where(eq(repositories.id, data.id))
|
||||
.returning();
|
||||
|
||||
@@ -44,3 +44,12 @@ export const fetchRepoContributors = async (
|
||||
|
||||
await queue.add("calculateUserPoints", { userId: result.userId });
|
||||
};
|
||||
|
||||
export const onFetchRepoContribFailed = async (
|
||||
data: FetchRepoContributorsType
|
||||
) => {
|
||||
await db
|
||||
.update(repositories)
|
||||
.set({ isPending: false, isError: true })
|
||||
.where(eq(repositories.id, data.id));
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const fetchUserRepos = async (data: FetchUserRepos) => {
|
||||
...repo,
|
||||
userId: user.id,
|
||||
lastUpdate: repo.lastUpdate.toISOString(),
|
||||
isPending: true,
|
||||
};
|
||||
|
||||
const [existing] = await tx
|
||||
|
||||
@@ -30,10 +30,6 @@ const github = {
|
||||
const $ = cheerio.load(response);
|
||||
|
||||
const name = $(selectors.user.name).text().trim();
|
||||
if (typeof name !== "string" || !name?.length) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const location = $(selectors.user.location).text().trim();
|
||||
const followers = intval($(selectors.user.followers).text().trim());
|
||||
const following = intval($(selectors.user.following).text().trim());
|
||||
@@ -45,7 +41,14 @@ const github = {
|
||||
achievements.push({ name, image });
|
||||
});
|
||||
|
||||
return { name, username, location, followers, following, achievements };
|
||||
return {
|
||||
name: name || username,
|
||||
username,
|
||||
location,
|
||||
followers,
|
||||
following,
|
||||
achievements,
|
||||
};
|
||||
},
|
||||
|
||||
async getRepositories(
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import type { JobNames } from "@server/jobs";
|
||||
const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, {
|
||||
connection: BULLMQ_CONNECTION,
|
||||
defaultJobOptions: {
|
||||
attempts: 5,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000,
|
||||
|
||||
@@ -8,3 +8,15 @@ export const intval = (value: any) => {
|
||||
const num = parseInt(value);
|
||||
return Number.isNaN(num) ? 0 : num;
|
||||
};
|
||||
|
||||
export const getLanguageLogo = (language: string) => {
|
||||
const alias: Record<string, string> = {
|
||||
gdscript: "godot",
|
||||
};
|
||||
|
||||
let lang = language.toLowerCase().replace(/[^a-zA-Z]/g, "");
|
||||
lang = alias[lang] || lang;
|
||||
const uri = `https://github.com/devicons/devicon/raw/master/icons/${lang}/${lang}-original.svg`;
|
||||
|
||||
return uri;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,11 @@ export const repositories = sqliteTable(
|
||||
languages: text("languages", { mode: "json" }).$type<Language[]>(),
|
||||
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
||||
|
||||
isPending: integer("is_pending", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isError: integer("is_error", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Job, Worker } from "bullmq";
|
||||
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts";
|
||||
import logger from "./lib/logger";
|
||||
import { jobs } from "./jobs";
|
||||
import { onFetchRepoContribFailed } from "./jobs/fetch-repo-contributors";
|
||||
|
||||
const handler = async (job: Job) => {
|
||||
const jobFn = (jobs as any)[job.name];
|
||||
@@ -13,6 +14,12 @@ const handler = async (job: Job) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const onJobRetriesExhausted = async (job: Job) => {
|
||||
if (job.name === "fetchRepoContributors") {
|
||||
await onFetchRepoContribFailed(job.data);
|
||||
}
|
||||
};
|
||||
|
||||
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
||||
connection: BULLMQ_CONNECTION,
|
||||
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
|
||||
@@ -28,6 +35,11 @@ worker.on("active", (job) => {
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
logger.child({ jobId: job?.id }).error(err);
|
||||
|
||||
if ((job?.attemptsMade || 0) >= (job?.opts.attempts || 0)) {
|
||||
logger.error(`Job ${job?.id} has reached the maximum number of attempts`);
|
||||
onJobRetriesExhausted(job!);
|
||||
}
|
||||
});
|
||||
|
||||
worker.on("completed", (job, result) => {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import db from "@server/db";
|
||||
import { getLanguageLogo } from "@server/lib/utils";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { count, desc, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
export type GetLeaderboardRes = {
|
||||
columns: { title: string; selector: string }[];
|
||||
rows: {
|
||||
id?: string | null;
|
||||
rank: number;
|
||||
name: string;
|
||||
sub: string;
|
||||
image?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export class LeaderboardRepository {
|
||||
async getTopUsers() {
|
||||
const data = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(desc(users.points))
|
||||
.limit(100);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Nama",
|
||||
selector: "name",
|
||||
},
|
||||
{
|
||||
title: "Points",
|
||||
selector: "sub",
|
||||
},
|
||||
];
|
||||
|
||||
const rows = data.map((data, idx) => ({
|
||||
id: data.username,
|
||||
rank: idx + 1,
|
||||
name: data.name,
|
||||
sub: `${data.points} pts`,
|
||||
image: data.avatar,
|
||||
}));
|
||||
|
||||
return { columns, rows } satisfies GetLeaderboardRes;
|
||||
}
|
||||
|
||||
async getTopLanguages() {
|
||||
const data = await db
|
||||
.select({
|
||||
language: repositories.language,
|
||||
count: count(),
|
||||
})
|
||||
.from(repositories)
|
||||
.groupBy(repositories.language)
|
||||
.orderBy(desc(count()));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Bahasa",
|
||||
selector: "name",
|
||||
},
|
||||
{
|
||||
title: "Total Repo",
|
||||
selector: "sub",
|
||||
},
|
||||
];
|
||||
|
||||
const rows = data
|
||||
.filter((i) => !!i.language)
|
||||
.map((data, idx) => ({
|
||||
rank: idx + 1,
|
||||
name: data.language,
|
||||
sub: `${data.count} repo`,
|
||||
image: getLanguageLogo(data.language),
|
||||
}));
|
||||
|
||||
return { columns, rows } satisfies GetLeaderboardRes;
|
||||
}
|
||||
|
||||
async getTopUsersByLang(lang: string) {
|
||||
const languages = lang.toLowerCase().split(",");
|
||||
|
||||
const data = await db
|
||||
.select({
|
||||
id: users.username,
|
||||
name: users.name,
|
||||
avatar: users.avatar,
|
||||
count: count(),
|
||||
points: users.points,
|
||||
})
|
||||
.from(repositories)
|
||||
.innerJoin(users, eq(users.id, repositories.userId))
|
||||
.where(inArray(sql`lower(${repositories.language})`, languages))
|
||||
.groupBy(users.id)
|
||||
.orderBy(desc(count()));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Nama",
|
||||
selector: "name",
|
||||
},
|
||||
{
|
||||
title: "Total Repo",
|
||||
selector: "sub",
|
||||
},
|
||||
];
|
||||
|
||||
const rows = data.map((data, idx) => ({
|
||||
id: data.id,
|
||||
rank: idx + 1,
|
||||
name: data.name,
|
||||
sub: `${data.count} repo`,
|
||||
image: data.avatar,
|
||||
}));
|
||||
|
||||
return { columns, rows } satisfies GetLeaderboardRes;
|
||||
}
|
||||
|
||||
async getUserRank(username: string) {
|
||||
const rankSubquery = db
|
||||
.select({
|
||||
userId: users.id,
|
||||
value: sql`rank() over (order by points desc)`.as("rank"),
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.points))
|
||||
.as("rank");
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
...getTableColumns(users),
|
||||
accessToken: sql`null`,
|
||||
rank: rankSubquery.value,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(rankSubquery, eq(users.id, rankSubquery.userId))
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: "User not found!" });
|
||||
}
|
||||
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, user.id))
|
||||
.orderBy(desc(repositories.stars), desc(repositories.forks));
|
||||
|
||||
const languageMap: Record<string, number> = {};
|
||||
repos
|
||||
.flatMap((i) => i.languages || [])
|
||||
.forEach((i) => {
|
||||
if (!languageMap[i.lang]) languageMap[i.lang] = 0;
|
||||
languageMap[i.lang] += i.amount;
|
||||
});
|
||||
|
||||
const totalLangWeight = Object.values(languageMap).reduce(
|
||||
(a, b) => a + b,
|
||||
0
|
||||
);
|
||||
let languages = [] as { name: string; percent: number }[];
|
||||
for (const [lang, amount] of Object.entries(languageMap)) {
|
||||
languages.push({
|
||||
name: lang,
|
||||
percent: (amount / totalLangWeight) * 100,
|
||||
});
|
||||
}
|
||||
languages = languages.sort((a, b) => b.percent - a.percent);
|
||||
|
||||
return { user, repositories: repos, languages };
|
||||
}
|
||||
}
|
||||
+12
-14
@@ -2,7 +2,7 @@ import db from "@server/db";
|
||||
import { JWT_SECRET } from "@server/lib/consts";
|
||||
import github from "@server/lib/github";
|
||||
import queue from "@server/lib/queue";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { users } from "@server/models";
|
||||
import { CreateUser } from "@server/models/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
@@ -50,7 +50,7 @@ export const auth = new Hono()
|
||||
|
||||
const userData: CreateUser = {
|
||||
username: ghUser.login,
|
||||
name: ghUser.name,
|
||||
name: ghUser.name || ghUser.login,
|
||||
avatar: ghUser.avatar_url,
|
||||
location: ghUser.location,
|
||||
accessToken,
|
||||
@@ -73,18 +73,16 @@ export const auth = new Hono()
|
||||
}
|
||||
|
||||
// Fetch latest user profile
|
||||
await queue.add("fetchUserProfile", { userId: user.id });
|
||||
|
||||
// Fetch user repositories
|
||||
const [hasRepo] = await db
|
||||
.select({ id: repositories.id })
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!hasRepo) {
|
||||
await queue.add("fetchUserRepos", { userId: user.id });
|
||||
}
|
||||
await queue.add(
|
||||
"fetchUserProfile",
|
||||
{ userId: user.id },
|
||||
{ jobId: `fetchUserProfile:${user.id}` }
|
||||
);
|
||||
await queue.add(
|
||||
"fetchUserRepos",
|
||||
{ userId: user.id },
|
||||
{ jobId: `fetchUserRepos:${user.id}` }
|
||||
);
|
||||
|
||||
const authToken = await jwt.sign({ id: user.id }, JWT_SECRET);
|
||||
setCookie(c, "token", authToken, { httpOnly: true });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import db from "@server/db";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { desc, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import {
|
||||
GetLeaderboardRes,
|
||||
LeaderboardRepository,
|
||||
} from "@server/repository/leaderboard";
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
export const leaderboard = new Hono()
|
||||
|
||||
@@ -10,12 +10,25 @@ export const leaderboard = new Hono()
|
||||
* Get users leaderboard
|
||||
*/
|
||||
.get("/", async (c) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(desc(users.points))
|
||||
.limit(100);
|
||||
const result = rows.map((data, idx) => ({ ...data, rank: idx + 1 }));
|
||||
const query = c.req.query();
|
||||
const repo = new LeaderboardRepository();
|
||||
|
||||
let result: GetLeaderboardRes;
|
||||
|
||||
switch (query.type) {
|
||||
case "lang":
|
||||
result = await repo.getTopLanguages();
|
||||
break;
|
||||
case "lang-users":
|
||||
result = await repo.getTopUsersByLang(query.lang);
|
||||
break;
|
||||
case "user":
|
||||
case "":
|
||||
result = await repo.getTopUsers();
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid query type");
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
})
|
||||
@@ -25,56 +38,8 @@ export const leaderboard = new Hono()
|
||||
*/
|
||||
.get("/:username", async (c) => {
|
||||
const { username } = c.req.param();
|
||||
const repo = new LeaderboardRepository();
|
||||
const result = await repo.getUserRank(username);
|
||||
|
||||
const rankSubquery = db
|
||||
.select({
|
||||
userId: users.id,
|
||||
value: sql`rank() over (order by points desc)`.as("rank"),
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.points))
|
||||
.as("rank");
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
...getTableColumns(users),
|
||||
accessToken: sql`null`,
|
||||
rank: rankSubquery.value,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(rankSubquery, eq(users.id, rankSubquery.userId))
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: "User not found!" });
|
||||
}
|
||||
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, user.id))
|
||||
.orderBy(desc(repositories.stars), desc(repositories.forks));
|
||||
|
||||
const languageMap: Record<string, number> = {};
|
||||
repos
|
||||
.flatMap((i) => i.languages || [])
|
||||
.forEach((i) => {
|
||||
if (!languageMap[i.lang]) languageMap[i.lang] = 0;
|
||||
languageMap[i.lang] += i.amount;
|
||||
});
|
||||
|
||||
const totalLangWeight = Object.values(languageMap).reduce(
|
||||
(a, b) => a + b,
|
||||
0
|
||||
);
|
||||
let languages = [] as { name: string; percent: number }[];
|
||||
for (const [lang, amount] of Object.entries(languageMap)) {
|
||||
languages.push({
|
||||
name: lang,
|
||||
percent: (amount / totalLangWeight) * 100,
|
||||
});
|
||||
}
|
||||
languages = languages.sort((a, b) => b.percent - a.percent);
|
||||
|
||||
return c.json({ user, repositories: repos, languages });
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user