mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2026-09-17 09:53:21 +07:00
feat: initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { Database } from "bun:sqlite";
|
||||
import * as schema from "../models";
|
||||
|
||||
const DATABASE_PATH = import.meta.env.DATABASE_PATH || "./data.db";
|
||||
|
||||
const sqlite = new Database(DATABASE_PATH);
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE `repositories` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`uri` text NOT NULL,
|
||||
`language` text NOT NULL,
|
||||
`stars` integer NOT NULL,
|
||||
`last_update` text NOT NULL,
|
||||
`languages` text,
|
||||
`contributors` text,
|
||||
`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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`avatar` text,
|
||||
`location` text,
|
||||
`followers` integer DEFAULT 0 NOT NULL,
|
||||
`following` integer DEFAULT 0 NOT NULL,
|
||||
`achievements` text DEFAULT '[]',
|
||||
`points` integer DEFAULT 0 NOT NULL,
|
||||
`commits` integer DEFAULT 0 NOT NULL,
|
||||
`line_of_codes` integer DEFAULT 0 NOT NULL,
|
||||
`github_id` integer,
|
||||
`access_token` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `repositories_name_idx` ON `repositories` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `repositories_uri_idx` ON `repositories` (`uri`);--> statement-breakpoint
|
||||
CREATE INDEX `repositories_language_idx` ON `repositories` (`language`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_github_id_unique` ON `users` (`github_id`);
|
||||
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ccf7929b-8198-4452-ad60-e02285ac8149",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"repositories": {
|
||||
"name": "repositories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"uri": {
|
||||
"name": "uri",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"language": {
|
||||
"name": "language",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stars": {
|
||||
"name": "stars",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_update": {
|
||||
"name": "last_update",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"languages": {
|
||||
"name": "languages",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"contributors": {
|
||||
"name": "contributors",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_name_idx": {
|
||||
"name": "repositories_name_idx",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"repositories_uri_idx": {
|
||||
"name": "repositories_uri_idx",
|
||||
"columns": [
|
||||
"uri"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"repositories_language_idx": {
|
||||
"name": "repositories_language_idx",
|
||||
"columns": [
|
||||
"language"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"repositories_user_id_users_id_fk": {
|
||||
"name": "repositories_user_id_users_id_fk",
|
||||
"tableFrom": "repositories",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"followers": {
|
||||
"name": "followers",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"following": {
|
||||
"name": "following",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"achievements": {
|
||||
"name": "achievements",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"points": {
|
||||
"name": "points",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"commits": {
|
||||
"name": "commits",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"line_of_codes": {
|
||||
"name": "line_of_codes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"github_id": {
|
||||
"name": "github_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_github_id_unique": {
|
||||
"name": "users_github_id_unique",
|
||||
"columns": [
|
||||
"github_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1723071912146,
|
||||
"tag": "0000_low_catseye",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { users } from "../models";
|
||||
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...");
|
||||
|
||||
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(
|
||||
[...Array(50)].map(() => ({
|
||||
username: faker.internet.userName(),
|
||||
name: faker.person.fullName(),
|
||||
location: faker.location.city(),
|
||||
followers: faker.number.int({ min: 0, max: 1000 }),
|
||||
following: faker.number.int({ min: 0, max: 1000 }),
|
||||
points: faker.number.int({ min: 20, max: 3000 }),
|
||||
commits: faker.number.int({ min: 20, max: 420 }),
|
||||
lineOfCodes: faker.number.int({ min: 1000, max: 300000 }),
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
await queue.add("fetchUserProfile", { userId: 1 });
|
||||
|
||||
logger.info("🌱 Database seeded");
|
||||
|
||||
process.exit();
|
||||
};
|
||||
|
||||
seed();
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || "./data.db";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./server/models/index.ts",
|
||||
out: "./server/db/migrations",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: { url: DATABASE_PATH },
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import db from "@server/db";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
type CalculateUserPointsType = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
const weights = {
|
||||
followers: 20,
|
||||
following: 10,
|
||||
achievements: 100,
|
||||
repositories: 1,
|
||||
contributorsAmount: 25,
|
||||
stars: 10,
|
||||
forks: 10,
|
||||
languagesKnown: 50,
|
||||
commits: 1,
|
||||
lineOfCodes: 0.01,
|
||||
};
|
||||
|
||||
export const calculateUserPoints = async (data: CalculateUserPointsType) => {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
|
||||
if (!user) {
|
||||
throw new Error("User not found!");
|
||||
}
|
||||
|
||||
let points = 0;
|
||||
let totalCommits = 0;
|
||||
let totalLineOfCodes = 0;
|
||||
|
||||
// User statistics
|
||||
points += user.followers * weights.followers;
|
||||
points += user.following * weights.following;
|
||||
points += user.achievements
|
||||
? user.achievements?.length * weights.achievements
|
||||
: 0;
|
||||
|
||||
// User repositories
|
||||
const repos = await db
|
||||
.select()
|
||||
.from(repositories)
|
||||
.where(eq(repositories.userId, user.id));
|
||||
points += repos.length * weights.repositories;
|
||||
|
||||
// Languages known
|
||||
const languages = new Set(
|
||||
repos.flatMap((i) => i.languages?.map((j) => j.lang))
|
||||
);
|
||||
points += languages.size * weights.languagesKnown;
|
||||
|
||||
// Activities
|
||||
repos.forEach((repo) => {
|
||||
const contributors = repo.contributors?.filter(
|
||||
(i) => i.author?.login !== user.username
|
||||
);
|
||||
points += contributors
|
||||
? contributors.length * weights.contributorsAmount
|
||||
: 0;
|
||||
|
||||
points += repo.stars * weights.stars;
|
||||
points += repo.forks * weights.forks;
|
||||
|
||||
const contrib = repo.contributors?.find(
|
||||
(i) => i.author?.login === user.username
|
||||
);
|
||||
const commits = contrib?.commits || 0;
|
||||
const lineOfCodes = contrib?.additions || 0;
|
||||
|
||||
points += commits * weights.commits;
|
||||
points += lineOfCodes * weights.lineOfCodes;
|
||||
totalCommits += commits;
|
||||
totalLineOfCodes += lineOfCodes;
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
points: Math.round(points),
|
||||
commits: totalCommits,
|
||||
lineOfCodes: totalLineOfCodes,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import db from "@server/db";
|
||||
import github from "@server/lib/github";
|
||||
import queue from "@server/lib/queue";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type FetchRepoContributorsType = {
|
||||
id: number;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export const fetchRepoContributors = async (
|
||||
data: FetchRepoContributorsType
|
||||
) => {
|
||||
const [repo] = await db
|
||||
.select({ id: repositories.id, userAccessToken: users.accessToken })
|
||||
.from(repositories)
|
||||
.innerJoin(users, eq(users.id, repositories.userId))
|
||||
.where(eq(repositories.id, data.id));
|
||||
|
||||
if (!repo) {
|
||||
throw new Error("Repository not found!");
|
||||
}
|
||||
|
||||
if (!repo.userAccessToken) {
|
||||
throw new Error("User access token not found!");
|
||||
}
|
||||
|
||||
const contributors = await github.getRepoContributors(data.uri, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${repo.userAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const [result] = await db
|
||||
.update(repositories)
|
||||
.set({ contributors })
|
||||
.where(eq(repositories.id, data.id))
|
||||
.returning();
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Cannot update repository!");
|
||||
}
|
||||
|
||||
await queue.add("calculateUserPoints", { userId: result.userId });
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import db from "@server/db";
|
||||
import github from "@server/lib/github";
|
||||
import queue from "@server/lib/queue";
|
||||
import { repositories } from "@server/models";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type FetchRepoDataJobType = {
|
||||
id: number;
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export const fetchRepoData = async (data: FetchRepoDataJobType) => {
|
||||
const details = await github.getRepoDetails(data.uri);
|
||||
|
||||
const [result] = await db
|
||||
.update(repositories)
|
||||
.set({ languages: details.languages })
|
||||
.where(eq(repositories.id, data.id))
|
||||
.returning();
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Repository not found!");
|
||||
}
|
||||
|
||||
await queue.add("calculateUserPoints", { userId: result.userId });
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import db from "@server/db";
|
||||
import github from "@server/lib/github";
|
||||
import queue from "@server/lib/queue";
|
||||
import { users } from "@server/models";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type FetchUserProfileType = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const fetchUserProfile = async (data: FetchUserProfileType) => {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
|
||||
if (!user) {
|
||||
throw new Error("User not found!");
|
||||
}
|
||||
const details = await github.getUser(user.username);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
name: details.name,
|
||||
followers: details.followers,
|
||||
following: details.following,
|
||||
location: details.location,
|
||||
achievements: details.achievements || user.achievements,
|
||||
})
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
await queue.add("calculateUserPoints", { userId: user.id });
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import db from "@server/db";
|
||||
import github from "@server/lib/github";
|
||||
import queue from "@server/lib/queue";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { FetchRepoDataJobType } from "./fetch-repo-data";
|
||||
|
||||
export type FetchUserRepos = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const fetchUserRepos = async (data: FetchUserRepos) => {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, data.userId));
|
||||
if (!user) {
|
||||
throw new Error("User not found!");
|
||||
}
|
||||
|
||||
const res = await github.getRepositories(user.username, {
|
||||
sort: "stargazers",
|
||||
fetchAll: true,
|
||||
});
|
||||
|
||||
const jobList = [] as FetchRepoDataJobType[];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const repo of res.repositories) {
|
||||
const data = {
|
||||
...repo,
|
||||
userId: user.id,
|
||||
lastUpdate: repo.lastUpdate.toISOString(),
|
||||
};
|
||||
|
||||
const [existing] = await tx
|
||||
.select({ id: repositories.id })
|
||||
.from(repositories)
|
||||
.where(
|
||||
and(
|
||||
eq(repositories.userId, data.userId),
|
||||
eq(repositories.name, data.name)
|
||||
)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(repositories)
|
||||
.set(data)
|
||||
.where(eq(repositories.id, existing.id));
|
||||
jobList.push({ id: existing.id, uri: data.uri });
|
||||
} else {
|
||||
const [result] = await tx.insert(repositories).values(data).returning();
|
||||
jobList.push({ id: result.id, uri: data.uri });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Queue fetch repo details
|
||||
queue.addBulk(jobList.map((data) => ({ name: "fetchRepoData", data })));
|
||||
queue.addBulk(
|
||||
jobList.map((data) => ({
|
||||
name: "fetchRepoContributors",
|
||||
data,
|
||||
opts: {
|
||||
attempts: 5,
|
||||
backoff: { type: "exponential", delay: 30000 },
|
||||
jobId: `contributors:${data.uri}`,
|
||||
},
|
||||
}))
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { calculateUserPoints } from "./calculate-user-points";
|
||||
import { fetchRepoContributors } from "./fetch-repo-contributors";
|
||||
import { fetchRepoData } from "./fetch-repo-data";
|
||||
import { fetchUserProfile } from "./fetch-user-profile";
|
||||
import { fetchUserRepos } from "./fetch-user-repos";
|
||||
|
||||
export const jobs = {
|
||||
fetchUserRepos,
|
||||
fetchRepoData,
|
||||
fetchRepoContributors,
|
||||
calculateUserPoints,
|
||||
fetchUserProfile,
|
||||
};
|
||||
|
||||
export type JobNames = keyof typeof jobs;
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
|
||||
export const __PROD = import.meta.env.NODE_ENV === "production";
|
||||
export const __DEV = !__PROD;
|
||||
|
||||
export const BULLMQ_CONNECTION = {
|
||||
host: import.meta.env.REDIS_HOST || "127.0.0.1",
|
||||
port: Number(import.meta.env.REDIS_PORT) || 6379,
|
||||
};
|
||||
export const BULLMQ_JOB_NAME = "ghcontribjob";
|
||||
|
||||
export const JWT_SECRET = import.meta.env.JWT_SECRET || "secret";
|
||||
@@ -0,0 +1,283 @@
|
||||
import * as cheerio from "cheerio";
|
||||
import { intval } from "./utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const GITHUB_URL = "https://github.com";
|
||||
const GITHUB_API_URL = "https://api.github.com";
|
||||
|
||||
const selectors = {
|
||||
user: {
|
||||
name: "h1.vcard-names > span.vcard-fullname",
|
||||
location: "li[itemprop='homeLocation'] span",
|
||||
followers: ".js-profile-editable-area a[href$='?tab=followers'] > span",
|
||||
following: ".js-profile-editable-area a[href$='?tab=following'] > span",
|
||||
achievement: "img.achievement-badge-sidebar",
|
||||
},
|
||||
|
||||
repo: {
|
||||
list: "div#user-repositories-list li",
|
||||
listForked: ':contains("Forked")',
|
||||
listLanguage: "span[itemprop='programmingLanguage']",
|
||||
listStars: "a[href$='stargazers']",
|
||||
listForks: "a[href$='forks']",
|
||||
langList: ".Layout-sidebar h2:contains('Languages')",
|
||||
},
|
||||
};
|
||||
|
||||
const github = {
|
||||
async getUser(username: string) {
|
||||
const response = await this.fetch(username);
|
||||
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());
|
||||
const achievements = [] as { name: string; image?: string }[];
|
||||
|
||||
$(selectors.user.achievement).each((_i, el) => {
|
||||
const name = $(el).attr("alt")?.split(" ")[1] || "";
|
||||
const image = $(el).attr("src");
|
||||
achievements.push({ name, image });
|
||||
});
|
||||
|
||||
return { name, username, location, followers, following, achievements };
|
||||
},
|
||||
|
||||
async getRepositories(
|
||||
username: string,
|
||||
params?: Partial<GetRepositoriesParams>
|
||||
) {
|
||||
const response = await this.fetch(username, {
|
||||
params: {
|
||||
tab: "repositories",
|
||||
type: "public",
|
||||
...params,
|
||||
},
|
||||
});
|
||||
const $ = cheerio.load(response);
|
||||
let repositories = [] as {
|
||||
name: string;
|
||||
uri: string;
|
||||
language: string;
|
||||
stars: number;
|
||||
forks: number;
|
||||
lastUpdate: Date;
|
||||
}[];
|
||||
|
||||
$(selectors.repo.list).each((_i, el) => {
|
||||
const isForked = $(el).find(selectors.repo.listForked).length > 0;
|
||||
if (isForked) return;
|
||||
|
||||
const name = $(el).find("h3 > a").text().trim();
|
||||
const language = $(el).find(selectors.repo.listLanguage).text().trim();
|
||||
const stars = intval($(el).find(selectors.repo.listStars).text().trim());
|
||||
const forks = intval($(el).find(selectors.repo.listForks).text().trim());
|
||||
const lastUpdate = $(el).find("relative-time").attr("datetime");
|
||||
|
||||
repositories.push({
|
||||
name,
|
||||
uri: `${username}/${name}`,
|
||||
language,
|
||||
stars,
|
||||
forks,
|
||||
lastUpdate: dayjs(lastUpdate).toDate(),
|
||||
});
|
||||
});
|
||||
|
||||
const prevPage = intval(
|
||||
$("a.prev_page")
|
||||
.attr("href")
|
||||
?.match(/page=(\d+)/)?.[1]
|
||||
);
|
||||
const nextPage = intval(
|
||||
$("a.next_page")
|
||||
.attr("href")
|
||||
?.match(/page=(\d+)/)?.[1]
|
||||
);
|
||||
|
||||
if (params?.fetchAll && nextPage > 1 && nextPage < 10) {
|
||||
try {
|
||||
const nextPageRes = await this.getRepositories(username, {
|
||||
...params,
|
||||
page: nextPage,
|
||||
});
|
||||
if (nextPageRes.repositories?.length > 0) {
|
||||
repositories = [...repositories, ...nextPageRes.repositories];
|
||||
}
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
return { repositories, prevPage, nextPage };
|
||||
},
|
||||
|
||||
async getRepoDetails(repo: string) {
|
||||
const response = await this.fetch(repo);
|
||||
const $ = cheerio.load(response);
|
||||
|
||||
const languages = [] as { lang: string; amount: number }[];
|
||||
|
||||
$(selectors.repo.langList)
|
||||
.parent()
|
||||
.find("ul > li > a")
|
||||
.each((_i, el) => {
|
||||
const lang = $(el).children().eq(1).text().trim();
|
||||
const percentage = $(el).children().eq(2).text().trim();
|
||||
const amount = parseFloat(percentage?.replace(/[^0-9.]/, "")) || 0;
|
||||
languages.push({ lang, amount });
|
||||
});
|
||||
|
||||
return { languages };
|
||||
},
|
||||
|
||||
async getRepoContributors(repo: string, options?: Partial<FetchOptions>) {
|
||||
const response = await this.fetch(`repos/${repo}/stats/contributors`, {
|
||||
...options,
|
||||
ghApi: true,
|
||||
headers: { accept: "application/json", ...(options?.headers || {}) },
|
||||
});
|
||||
|
||||
if (!Array.isArray(response)) {
|
||||
throw new Error("Invalid response: " + JSON.stringify(response));
|
||||
}
|
||||
|
||||
const result = response
|
||||
.map((item: any) => {
|
||||
const { author, total, weeks } = item;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let commits = 0;
|
||||
|
||||
weeks.forEach((week: any) => {
|
||||
additions += week.a || 0;
|
||||
deletions += week.d || 0;
|
||||
commits += week.c || 0;
|
||||
});
|
||||
|
||||
return { author, total, additions, deletions, commits };
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async getAllData(username: string, options?: Partial<GetAllDataOptions>) {
|
||||
const user = await this.getUser(username);
|
||||
const repositories = [] as (Repository & {
|
||||
languages: Language[];
|
||||
contributors: Contributors;
|
||||
})[];
|
||||
|
||||
const _repos = await this.getRepositories(username, {
|
||||
sort: "stargazers",
|
||||
fetchAll: true,
|
||||
});
|
||||
|
||||
const repoCount = Math.min(
|
||||
_repos.repositories.length,
|
||||
options?.maxRepo || Number.POSITIVE_INFINITY
|
||||
);
|
||||
|
||||
for (let idx = 0; idx < repoCount; idx++) {
|
||||
const repo = _repos.repositories[idx];
|
||||
const [details, contributors] = await Promise.all([
|
||||
this.getRepoDetails(repo.uri),
|
||||
this.getRepoContributors(repo.uri),
|
||||
]);
|
||||
|
||||
repositories.push({
|
||||
...repo,
|
||||
languages: details.languages,
|
||||
contributors,
|
||||
});
|
||||
}
|
||||
|
||||
return { user, repositories };
|
||||
},
|
||||
|
||||
async fetch<T = any>(path: string, options?: Partial<FetchOptions>) {
|
||||
const url = new URL(
|
||||
"/" + path,
|
||||
options?.ghApi ? GITHUB_API_URL : GITHUB_URL
|
||||
);
|
||||
|
||||
if (options?.params) {
|
||||
Object.entries(options.params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, value as string);
|
||||
});
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
||||
...(options?.headers || {}),
|
||||
};
|
||||
|
||||
if (options?.xhr) {
|
||||
headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
}
|
||||
|
||||
const init = {
|
||||
method: "GET",
|
||||
headers,
|
||||
referrer: options?.referrer || GITHUB_URL,
|
||||
};
|
||||
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
|
||||
const type = res.headers.get("Content-Type");
|
||||
|
||||
if (type?.includes("application/json")) {
|
||||
return res.json() as T;
|
||||
}
|
||||
|
||||
return res.text();
|
||||
},
|
||||
};
|
||||
|
||||
type FetchOptions = {
|
||||
xhr: boolean;
|
||||
ghApi: boolean;
|
||||
params: any;
|
||||
headers: any;
|
||||
referrer: string;
|
||||
};
|
||||
|
||||
type GetRepositoriesParams = {
|
||||
page: string | number;
|
||||
sort: "stargazers" | "name" | null;
|
||||
fetchAll: boolean;
|
||||
};
|
||||
|
||||
type GetAllDataOptions = {
|
||||
maxRepo: number;
|
||||
};
|
||||
|
||||
export type GithubUser = Awaited<ReturnType<typeof github.getUser>>;
|
||||
|
||||
export type Repository = Awaited<
|
||||
ReturnType<typeof github.getRepositories>
|
||||
>["repositories"][number];
|
||||
|
||||
export type Language = Awaited<
|
||||
ReturnType<typeof github.getRepoDetails>
|
||||
>["languages"][number];
|
||||
|
||||
export type Contributors = Awaited<
|
||||
ReturnType<typeof github.getRepoContributors>
|
||||
>;
|
||||
|
||||
export type Contributor = NonNullable<Contributors>[number];
|
||||
|
||||
export type Achievement = GithubUser["achievements"][number];
|
||||
|
||||
export default github;
|
||||
@@ -0,0 +1,17 @@
|
||||
import pino from "pino";
|
||||
import { __DEV } from "./consts";
|
||||
|
||||
const logger = pino(
|
||||
__DEV
|
||||
? {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Queue } from "bullmq";
|
||||
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./consts";
|
||||
import logger from "./logger";
|
||||
import type { JobNames } from "@server/jobs";
|
||||
|
||||
const queue = new Queue<any, any, JobNames>(BULLMQ_JOB_NAME, {
|
||||
connection: BULLMQ_CONNECTION,
|
||||
defaultJobOptions: {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
queue.on("error", logger.error);
|
||||
|
||||
export default queue;
|
||||
@@ -0,0 +1,10 @@
|
||||
//
|
||||
|
||||
export const intval = (value: any) => {
|
||||
if (typeof value === "number" && !Number.isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const num = parseInt(value);
|
||||
return Number.isNaN(num) ? 0 : num;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import router from "./router";
|
||||
import logger from "./lib/logger";
|
||||
import { __DEV, __PROD } from "./lib/consts";
|
||||
|
||||
const HOST = import.meta.env.HOST || "127.0.0.1";
|
||||
const PORT = Number(import.meta.env.PORT) || 5589;
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((err, c) => {
|
||||
logger.error(err);
|
||||
return c.text(err.message, 500);
|
||||
});
|
||||
|
||||
// Allow all origin on development
|
||||
if (__DEV) {
|
||||
app.use(cors({ origin: "*" }));
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.text("OK"));
|
||||
|
||||
// Serve prod client app
|
||||
if (__PROD) {
|
||||
app.use("*", serveStatic({ root: "./dist/client" }));
|
||||
}
|
||||
|
||||
// API router
|
||||
app.route("/api", router);
|
||||
|
||||
Bun.serve({
|
||||
fetch: app.fetch,
|
||||
hostname: HOST,
|
||||
port: PORT,
|
||||
});
|
||||
|
||||
logger.info(`Server started at http://${HOST}:${PORT}`);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { JWT_SECRET } from "@server/lib/consts";
|
||||
import { Context, Next } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import * as jwt from "hono/jwt";
|
||||
|
||||
type AuthOptions = {
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export const auth =
|
||||
(opt?: Partial<AuthOptions>) => async (c: Context, next: Next) => {
|
||||
try {
|
||||
const token = getCookie(c, "token");
|
||||
if (!token) {
|
||||
throw new Error("No token found!");
|
||||
}
|
||||
|
||||
const jwtData: any = await jwt.verify(token, JWT_SECRET);
|
||||
const userId = jwtData.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No user id found!");
|
||||
}
|
||||
|
||||
c.set("userId", userId);
|
||||
} catch (err) {
|
||||
if (opt?.required) {
|
||||
throw new HTTPException(401, { message: "Unauthorized!" });
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { users } from "./users";
|
||||
export { repositories } from "./repositories";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Contributor, Language } from "@server/lib/github";
|
||||
import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm";
|
||||
import { text, sqliteTable, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { users } from "./users";
|
||||
|
||||
export const repositories = sqliteTable(
|
||||
"repositories",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
|
||||
name: text("name").notNull(),
|
||||
uri: text("uri").notNull(),
|
||||
language: text("language").notNull(),
|
||||
stars: integer("stars").notNull(),
|
||||
forks: integer("stars").notNull(),
|
||||
lastUpdate: text("last_update").notNull(),
|
||||
languages: text("languages", { mode: "json" }).$type<Language[]>(),
|
||||
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.$onUpdate(() => sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => ({
|
||||
nameIdx: index("repositories_name_idx").on(t.name),
|
||||
uriIdx: index("repositories_uri_idx").on(t.uri),
|
||||
language: index("repositories_language_idx").on(t.language),
|
||||
})
|
||||
);
|
||||
|
||||
export type Repository = InferSelectModel<typeof repositories>;
|
||||
export type CreateRepository = InferInsertModel<typeof repositories>;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Achievement } from "@server/lib/github";
|
||||
import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm";
|
||||
import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
avatar: text("avatar"),
|
||||
location: text("location"),
|
||||
followers: integer("followers").notNull().default(0),
|
||||
following: integer("following").notNull().default(0),
|
||||
achievements: text("achievements", { mode: "json" })
|
||||
.$type<Achievement[]>()
|
||||
.default([]),
|
||||
points: integer("points").notNull().default(0),
|
||||
commits: integer("commits").notNull().default(0),
|
||||
lineOfCodes: integer("line_of_codes").notNull().default(0),
|
||||
githubId: integer("github_id").unique(),
|
||||
accessToken: text("access_token"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.$onUpdate(() => sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type CreateUser = InferInsertModel<typeof users>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Job, Worker } from "bullmq";
|
||||
import { BULLMQ_CONNECTION, BULLMQ_JOB_NAME } from "./lib/consts";
|
||||
import logger from "./lib/logger";
|
||||
import { jobs } from "./jobs";
|
||||
|
||||
const handler = async (job: Job) => {
|
||||
const jobFn = (jobs as any)[job.name];
|
||||
|
||||
if (jobFn) {
|
||||
return jobFn(job.data);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
||||
connection: BULLMQ_CONNECTION,
|
||||
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
|
||||
removeOnComplete: { count: 0 },
|
||||
removeOnFail: { count: 0 },
|
||||
});
|
||||
|
||||
worker.on("error", logger.error);
|
||||
|
||||
worker.on("active", (job) => {
|
||||
logger.info(`Job ${job.name}.${job.id} started.`);
|
||||
});
|
||||
|
||||
worker.on("failed", (job, err) => {
|
||||
logger.child({ jobId: job?.id }).error(err);
|
||||
});
|
||||
|
||||
worker.on("completed", (job, result) => {
|
||||
logger.info({
|
||||
msg: `Job ${job.name}.${job.id} completed.`,
|
||||
result,
|
||||
});
|
||||
});
|
||||
|
||||
worker.on("ready", () => {
|
||||
logger.info("Worker ready!");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import { auth } from "./routes/auth";
|
||||
import { leaderboard } from "./routes/leaderboard";
|
||||
|
||||
const router = new Hono()
|
||||
.route("/auth", auth)
|
||||
.route("/leaderboard", leaderboard);
|
||||
|
||||
export type AppType = typeof router;
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
userId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,115 @@
|
||||
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 { CreateUser } from "@server/models/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import * as jwt from "hono/jwt";
|
||||
import { auth as authMiddleware } from "../middlewares/auth";
|
||||
|
||||
const { GITHUB_CLIENT_ID, GITHUB_SECRET_KEY } = import.meta.env;
|
||||
|
||||
export const auth = new Hono()
|
||||
|
||||
/**
|
||||
* Redirect to github oauth
|
||||
*/
|
||||
.get("/login", (c) => {
|
||||
return c.redirect(
|
||||
"https://github.com/login/oauth/authorize?client_id=" + GITHUB_CLIENT_ID
|
||||
);
|
||||
})
|
||||
|
||||
/**
|
||||
* Auth callback
|
||||
*/
|
||||
.get("/callback", async (c) => {
|
||||
const code = c.req.query("code");
|
||||
const result = await github.fetch("login/oauth/access_token", {
|
||||
params: {
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
client_secret: GITHUB_SECRET_KEY,
|
||||
code,
|
||||
},
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = result.access_token;
|
||||
const ghUser = await github.fetch("user", {
|
||||
ghApi: true,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
const userData: CreateUser = {
|
||||
username: ghUser.login,
|
||||
name: ghUser.name,
|
||||
avatar: ghUser.avatar_url,
|
||||
location: ghUser.location,
|
||||
accessToken,
|
||||
githubId: ghUser.id,
|
||||
followers: ghUser.followers,
|
||||
following: ghUser.following,
|
||||
};
|
||||
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values(userData)
|
||||
.onConflictDoUpdate({
|
||||
target: users.username,
|
||||
set: userData,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Auth user failed!");
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
const authToken = await jwt.sign({ id: user.id }, JWT_SECRET);
|
||||
setCookie(c, "token", authToken, { httpOnly: true });
|
||||
|
||||
return c.redirect("/");
|
||||
})
|
||||
|
||||
/**
|
||||
* Get authenticated user
|
||||
*/
|
||||
.get("/user", authMiddleware(), async (c) => {
|
||||
const userId = c.get("userId");
|
||||
if (!userId) {
|
||||
return c.json(null);
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||
|
||||
return c.json(user ? { ...user, accessToken: undefined } : null);
|
||||
})
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
.get("/logout", authMiddleware({ required: true }), async (c) => {
|
||||
setCookie(c, "token", "", { httpOnly: true });
|
||||
return c.redirect("/");
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import db from "@server/db";
|
||||
import { repositories, users } from "@server/models";
|
||||
import { desc, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
|
||||
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 }));
|
||||
|
||||
return c.json(result);
|
||||
})
|
||||
|
||||
/**
|
||||
* Get specific user data
|
||||
*/
|
||||
.get("/:username", async (c) => {
|
||||
const { username } = c.req.param();
|
||||
|
||||
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 });
|
||||
});
|
||||
Reference in New Issue
Block a user