From b742ec328558e06b489bb4d7515b5f29dcc09349 Mon Sep 17 00:00:00 2001 From: META DREAMER Date: Tue, 21 Jan 2025 18:22:51 -0700 Subject: [PATCH] Implement SQLite database - Schema defined in TS - Ingests from ./data folder as source of truth (can be blown up and recreated anytime) - Example implementation of scoring algorithm / config in TS, so that scoring layer can be iterated on separately from the data wrangling, or made user configurable even. --- .gitignore | 6 + README.md | 16 + bun.lockb | Bin 188252 -> 226018 bytes drizzle.config.ts | 11 + drizzle/0000_aromatic_slipstream.sql | 61 ++++ drizzle/meta/0000_snapshot.json | 457 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 + package.json | 12 +- scripts/init-db.ts | 18 ++ src/lib/data/db.ts | 17 + src/lib/data/ingest.ts | 265 ++++++++++++++++ src/lib/data/queries.ts | 113 +++++++ src/lib/data/schema.ts | 88 ++++++ src/lib/data/scoring.ts | 329 +++++++++++++++++++ src/lib/data/types.ts | 131 ++++++++ src/lib/date-utils.ts | 6 +- tsconfig.json | 3 +- 17 files changed, 1540 insertions(+), 6 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_aromatic_slipstream.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 scripts/init-db.ts create mode 100644 src/lib/data/db.ts create mode 100644 src/lib/data/ingest.ts create mode 100644 src/lib/data/queries.ts create mode 100644 src/lib/data/schema.ts create mode 100644 src/lib/data/scoring.ts create mode 100644 src/lib/data/types.ts diff --git a/.gitignore b/.gitignore index 4b833d4..d6d9a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +data/db.sqlite +data/db.sqlite-journal +data/db.sqlite-shm +data/db.sqlite-wal diff --git a/README.md b/README.md index b109ef6..14db923 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,22 @@ owner=\"your_org\" repo=\"your_repo\" ``` +### SQLite Database Setup + +The SQLite database stores the JSON data generated by the scripts into a more efficient format for querying. Most of the db related code is in `src/lib/data`. Here's how to set it up: + +1. Initialize the database and ingest historical data: + +```bash +bun run init-db +``` + +This will: + +- Create a SQLite database in the `data/` directory +- Set up the required tables and schema +- Ingest historical contributor data + ## Usage ### Manual Data Collection diff --git a/bun.lockb b/bun.lockb index d1ea6304699a520b71b544a885eb14e7f6465f88..37827897cf161712b28bac38dd64c63dc173a887 100755 GIT binary patch delta 71177 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%0D9ET)fU6rV>m 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 delta 45451 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 statement-breakpoint +CREATE TABLE `user_daily_summaries` ( + `id` text PRIMARY KEY NOT NULL, + `username` text, + `date` text NOT NULL, + `score` real DEFAULT 0 NOT NULL, + `summary` text DEFAULT '', + `total_commits` integer DEFAULT 0 NOT NULL, + `total_prs` integer DEFAULT 0 NOT NULL, + `additions` integer DEFAULT 0 NOT NULL, + `deletions` integer DEFAULT 0 NOT NULL, + `changed_files` integer DEFAULT 0 NOT NULL, + `commits` text DEFAULT '[]' NOT NULL, + `pull_requests` text DEFAULT '[]' NOT NULL, + `issues` text DEFAULT '[]' NOT NULL, + FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_user_daily_summaries_username` ON `user_daily_summaries` (`username`);--> statement-breakpoint +CREATE INDEX `idx_user_daily_summaries_date` ON `user_daily_summaries` (`date`);--> statement-breakpoint +CREATE TABLE `user_stats` ( + `username` text PRIMARY KEY NOT NULL, + `total_prs` integer DEFAULT 0 NOT NULL, + `merged_prs` integer DEFAULT 0 NOT NULL, + `closed_prs` integer DEFAULT 0 NOT NULL, + `total_files` integer DEFAULT 0 NOT NULL, + `total_additions` integer DEFAULT 0 NOT NULL, + `total_deletions` integer DEFAULT 0 NOT NULL, + `files_by_type` text DEFAULT '{}' NOT NULL, + `prs_by_month` text DEFAULT '{}' NOT NULL, + `focus_areas` text DEFAULT '[]' NOT NULL, + `last_updated` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `user_tag_scores` ( + `id` text PRIMARY KEY NOT NULL, + `username` text, + `tag` text, + `score` real DEFAULT 0 NOT NULL, + `level` integer DEFAULT 0 NOT NULL, + `progress` real DEFAULT 0 NOT NULL, + `points_to_next` real DEFAULT 0 NOT NULL, + `last_updated` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`tag`) REFERENCES `tags`(`name`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `idx_user_tag_scores_username` ON `user_tag_scores` (`username`);--> statement-breakpoint +CREATE TABLE `users` ( + `username` text PRIMARY KEY NOT NULL, + `avatar_url` text DEFAULT '', + `last_updated` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `score` real DEFAULT 0 NOT NULL +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..cde6bba --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,457 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "36e762c0-daed-4ef6-92d1-47f9fd907f32", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "tags": { + "name": "tags", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_daily_summaries": { + "name": "user_daily_summaries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "total_commits": { + "name": "total_commits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_prs": { + "name": "total_prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "commits": { + "name": "commits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pull_requests": { + "name": "pull_requests", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "issues": { + "name": "issues", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + } + }, + "indexes": { + "idx_user_daily_summaries_username": { + "name": "idx_user_daily_summaries_username", + "columns": [ + "username" + ], + "isUnique": false + }, + "idx_user_daily_summaries_date": { + "name": "idx_user_daily_summaries_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_daily_summaries_username_users_username_fk": { + "name": "user_daily_summaries_username_users_username_fk", + "tableFrom": "user_daily_summaries", + "tableTo": "users", + "columnsFrom": [ + "username" + ], + "columnsTo": [ + "username" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "total_prs": { + "name": "total_prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "merged_prs": { + "name": "merged_prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_prs": { + "name": "closed_prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_files": { + "name": "total_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_additions": { + "name": "total_additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_deletions": { + "name": "total_deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "files_by_type": { + "name": "files_by_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "prs_by_month": { + "name": "prs_by_month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "focus_areas": { + "name": "focus_areas", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_username_users_username_fk": { + "name": "user_stats_username_users_username_fk", + "tableFrom": "user_stats", + "tableTo": "users", + "columnsFrom": [ + "username" + ], + "columnsTo": [ + "username" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tag_scores": { + "name": "user_tag_scores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "points_to_next": { + "name": "points_to_next", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_tag_scores_username": { + "name": "idx_user_tag_scores_username", + "columns": [ + "username" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_tag_scores_username_users_username_fk": { + "name": "user_tag_scores_username_users_username_fk", + "tableFrom": "user_tag_scores", + "tableTo": "users", + "columnsFrom": [ + "username" + ], + "columnsTo": [ + "username" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_tag_scores_tag_tags_name_fk": { + "name": "user_tag_scores_tag_tags_name_fk", + "tableFrom": "user_tag_scores", + "tableTo": "tags", + "columnsFrom": [ + "tag" + ], + "columnsTo": [ + "name" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..7ab73a6 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1737165846227, + "tag": "0000_aromatic_slipstream", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 1d14d68..6c83fa0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "next lint", - "serve": "bunx serve@latest out" + "check": "bun run lint & bunx tsc --noEmit", + "serve": "bunx serve@latest out", + "init-db": "bun run scripts/init-db.ts", + "db:generate": "bunx drizzle-kit generate" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.2", @@ -20,18 +23,23 @@ "@tanstack/react-virtual": "^3.11.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "drizzle-orm": "^0.38.4", + "fuzzysort": "^3.1.0", "lucide-react": "^0.471.0", "next": "^15.1.4", "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "zod": "^3.24.1" }, "devDependencies": { + "@types/bun": "^1.1.17", "@types/node": "^22.10.5", "@types/react": "^19.0.5", "@types/react-dom": "^19.0.3", "autoprefixer": "^10.4.20", + "drizzle-kit": "^0.30.2", "eslint": "^9", "eslint-config-next": "15.1.4", "postcss": "^8.4.49", diff --git a/scripts/init-db.ts b/scripts/init-db.ts new file mode 100644 index 0000000..edf095e --- /dev/null +++ b/scripts/init-db.ts @@ -0,0 +1,18 @@ +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { db } from "@/lib/data/db"; +import { ingestHistoricalData } from "@/lib/data/ingest"; + +async function main() { + console.log("Initializing database..."); + await migrate(db, { migrationsFolder: "./drizzle" }); + + await ingestHistoricalData(); + + console.log("Done!"); + process.exit(0); +} + +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); diff --git a/src/lib/data/db.ts b/src/lib/data/db.ts new file mode 100644 index 0000000..ec9bf0a --- /dev/null +++ b/src/lib/data/db.ts @@ -0,0 +1,17 @@ +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import path from "path"; +import * as schema from "./schema"; + +// Initialize SQLite database with WAL mode for better performance +const sqlite = new Database(path.join(process.cwd(), "data/db.sqlite"), { + create: true, +}); +sqlite.exec("PRAGMA journal_mode = WAL;"); // Enable WAL mode for better performance + +export const db = drizzle(sqlite, { schema }); + +// Ensure database is closed when the process exits +process.on("exit", () => { + sqlite.close(); +}); diff --git a/src/lib/data/ingest.ts b/src/lib/data/ingest.ts new file mode 100644 index 0000000..5c068a1 --- /dev/null +++ b/src/lib/data/ingest.ts @@ -0,0 +1,265 @@ +import fs from "fs/promises"; +import path from "path"; +import { glob } from "glob"; +import { db } from "./db"; +import { + users, + userDailySummaries, + userStats, + tags, + userTagScores, +} from "./schema"; +import { extractDateFromFilename } from "@/lib/date-utils"; +import { ContributorDataSchema, AnalysisDataSchema } from "@/lib/data/types"; +import { calculateScore } from "@/lib/data/scoring"; +import { z } from "zod"; + +export async function ingestHistoricalData() { + console.log("Starting data ingestion..."); + const historyDir = path.join(process.cwd(), "data/daily/history"); + + // Process contributors files + const contributorFiles = await glob("contributors_*.json", { + cwd: historyDir, + }); + console.log(`Found ${contributorFiles.length} contributor files to process`); + + let processedFiles = 0; + let skippedFiles = 0; + let errorFiles = 0; + + for (const file of contributorFiles) { + const date = extractDateFromFilename(file); + if (!date) { + console.warn(`Skipping ${file}: Could not extract date from filename`); + skippedFiles++; + continue; + } + + try { + const content = await fs.readFile(path.join(historyDir, file), "utf-8"); + const rawData = JSON.parse(content); + const contributors = z.array(ContributorDataSchema).parse(rawData); + + console.log( + `Processing ${file} with ${contributors.length} contributors...` + ); + + for (const contributor of contributors) { + try { + const score = calculateScore(contributor); + const now = new Date().toISOString(); + + // Upsert user + await db + .insert(users) + .values({ + username: contributor.contributor, + avatarUrl: contributor.avatar_url ?? "", + score, + lastUpdated: now, + }) + .onConflictDoUpdate({ + target: users.username, + set: { + avatarUrl: contributor.avatar_url ?? "", + score, + lastUpdated: now, + }, + }); + + // Insert daily summary + const totalAdditions = + contributor.activity.code.commits?.reduce( + (sum, commit) => sum + commit.additions, + 0 + ) ?? 0; + const totalDeletions = + contributor.activity.code.commits?.reduce( + (sum, commit) => sum + commit.deletions, + 0 + ) ?? 0; + const totalChangedFiles = + contributor.activity.code.commits?.reduce( + (sum, commit) => sum + commit.changed_files, + 0 + ) ?? 0; + + await db + .insert(userDailySummaries) + .values({ + id: `${contributor.contributor}_${date}`, + username: contributor.contributor, + date, + score, + summary: contributor.summary, + totalCommits: contributor.activity.code.total_commits, + totalPRs: contributor.activity.code.total_prs, + additions: totalAdditions, + deletions: totalDeletions, + changedFiles: totalChangedFiles, + commits: JSON.stringify(contributor.activity.code.commits ?? []), + pullRequests: JSON.stringify( + contributor.activity.code.pull_requests ?? [] + ), + issues: JSON.stringify(contributor.activity.issues?.opened ?? []), + }) + .onConflictDoUpdate({ + target: userDailySummaries.id, + set: { + score, + summary: contributor.summary, + totalCommits: contributor.activity.code.total_commits, + totalPRs: contributor.activity.code.total_prs, + additions: totalAdditions, + deletions: totalDeletions, + changedFiles: totalChangedFiles, + commits: JSON.stringify( + contributor.activity.code.commits ?? [] + ), + pullRequests: JSON.stringify( + contributor.activity.code.pull_requests ?? [] + ), + issues: JSON.stringify( + contributor.activity.issues?.opened ?? [] + ), + }, + }); + } catch (contributorError) { + console.error( + `Error processing contributor ${contributor.contributor} in ${file}:`, + contributorError + ); + continue; + } + } + processedFiles++; + } catch (error) { + console.error(`Error processing file ${file}:`, error); + errorFiles++; + } + } + + console.log( + `\nContributor files summary:\n` + + `- Processed: ${processedFiles}\n` + + `- Skipped: ${skippedFiles}\n` + + `- Errors: ${errorFiles}\n` + + `- Total: ${contributorFiles.length}` + ); + + // Process analysis data + try { + console.log("\nProcessing analysis data..."); + const analysisContent = await fs.readFile( + path.join(process.cwd(), "data/analysis.json"), + "utf-8" + ); + const rawAnalysis = JSON.parse(analysisContent); + const analysis = z + .object({ + contributors: z.array(AnalysisDataSchema), + }) + .parse(rawAnalysis); + + // First, collect all unique tags and insert them + const uniqueTags = new Set(); + for (const contributor of analysis.contributors) { + contributor.tags.forEach((tag) => uniqueTags.add(tag)); + } + + console.log(`Inserting ${uniqueTags.size} unique tags...`); + const now = new Date().toISOString(); + + // Insert all tags + for (const tagName of uniqueTags) { + await db + .insert(tags) + .values({ + name: tagName, + description: "", + createdAt: now, + lastUpdated: now, + }) + .onConflictDoUpdate({ + target: tags.name, + set: { + lastUpdated: now, + }, + }); + } + + for (const contributor of analysis.contributors) { + // Insert user stats + await db + .insert(userStats) + .values({ + username: contributor.username, + totalPRs: contributor.stats.total_prs, + mergedPRs: contributor.stats.merged_prs, + closedPRs: contributor.stats.closed_prs, + totalFiles: contributor.stats.total_files, + totalAdditions: contributor.stats.total_additions, + totalDeletions: contributor.stats.total_deletions, + filesByType: JSON.stringify(contributor.stats.files_by_type), + prsByMonth: JSON.stringify(contributor.stats.prs_by_month), + focusAreas: JSON.stringify(contributor.focus_areas), + lastUpdated: now, + }) + .onConflictDoUpdate({ + target: userStats.username, + set: { + totalPRs: contributor.stats.total_prs, + mergedPRs: contributor.stats.merged_prs, + closedPRs: contributor.stats.closed_prs, + totalFiles: contributor.stats.total_files, + totalAdditions: contributor.stats.total_additions, + totalDeletions: contributor.stats.total_deletions, + filesByType: JSON.stringify(contributor.stats.files_by_type), + prsByMonth: JSON.stringify(contributor.stats.prs_by_month), + focusAreas: JSON.stringify(contributor.focus_areas), + lastUpdated: now, + }, + }); + + // Insert user tag scores + for (const tag of contributor.tags) { + const score = contributor.tag_scores[tag] || 0; + const level = contributor.tag_levels[tag]; + if (!level) continue; + + await db + .insert(userTagScores) + .values({ + id: `${contributor.username}_${tag}`, + username: contributor.username, + tag, + score, + level: level.level, + progress: level.progress, + pointsToNext: level.points_next_level, + lastUpdated: now, + }) + .onConflictDoUpdate({ + target: userTagScores.id, + set: { + score, + level: level.level, + progress: level.progress, + pointsToNext: level.points_next_level, + lastUpdated: now, + }, + }); + } + } + } catch (error) { + console.error("Error processing analysis data:", error); + throw new Error("Failed to process analysis data. Ingestion incomplete."); + } + + if (errorFiles > 0) { + console.warn(`\nCompleted with ${errorFiles} file(s) containing errors`); + } else { + console.log("\nData ingestion completed successfully!"); + } +} diff --git a/src/lib/data/queries.ts b/src/lib/data/queries.ts new file mode 100644 index 0000000..9784432 --- /dev/null +++ b/src/lib/data/queries.ts @@ -0,0 +1,113 @@ +import { desc, eq, sql } from "drizzle-orm"; +import { db } from "./db"; +import { users, userDailySummaries, userStats } from "./schema"; + +export async function getAllDailySummaryDates(): Promise { + const results = await db + .select({ + date: userDailySummaries.date, + }) + .from(userDailySummaries) + .groupBy(userDailySummaries.date) + .orderBy(desc(userDailySummaries.date)); + + return results + .map((r) => r.date) + .filter((date): date is string => date !== null); +} + +export async function getContributorProfile(username: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + return user; +} + +export async function getContributorRecentPRs(username: string, limit = 5) { + const [summary] = await db + .select({ + pullRequests: userDailySummaries.pullRequests, + }) + .from(userDailySummaries) + .where(eq(userDailySummaries.username, username)) + .orderBy(desc(userDailySummaries.date)) + .limit(1); + + if (!summary) return []; + + const prs = JSON.parse(summary.pullRequests); + return prs.slice(0, limit); +} + +export async function getContributorRecentCommits( + username: string, + limit = 10 +) { + const [summary] = await db + .select({ + commits: userDailySummaries.commits, + }) + .from(userDailySummaries) + .where(eq(userDailySummaries.username, username)) + .orderBy(desc(userDailySummaries.date)) + .limit(1); + + if (!summary) return []; + + const commits = JSON.parse(summary.commits); + return commits.slice(0, limit); +} + +export async function getContributorStats(username: string) { + const [stats] = await db + .select() + .from(userStats) + .where(eq(userStats.username, username)) + .limit(1); + + return stats; +} + +export async function getTopContributors(limit = 10) { + // Get latest summary for each user using a correlated subquery + const latestSummaries = await db + .select({ + username: userDailySummaries.username, + avatarUrl: users.avatarUrl, + score: userDailySummaries.score, + summary: userDailySummaries.summary, + }) + .from(userDailySummaries) + .innerJoin(users, eq(users.username, userDailySummaries.username)) + .where( + sql`${userDailySummaries.date} = ( + SELECT MAX(date) + FROM ${userDailySummaries} AS u2 + WHERE u2.username = ${userDailySummaries.username} + )` + ) + .orderBy(desc(userDailySummaries.score)) + .limit(limit); + + return latestSummaries; +} + +export async function getContributorDailySummaries( + username: string, + limit?: number +) { + const query = db + .select() + .from(userDailySummaries) + .where(eq(userDailySummaries.username, username)) + .orderBy(desc(userDailySummaries.date)); + + if (limit) { + query.limit(limit); + } + + return query; +} diff --git a/src/lib/data/schema.ts b/src/lib/data/schema.ts new file mode 100644 index 0000000..2323993 --- /dev/null +++ b/src/lib/data/schema.ts @@ -0,0 +1,88 @@ +import { sql } from "drizzle-orm"; +import { + sqliteTable, + text, + integer, + real, + index, +} from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + username: text("username").primaryKey(), + avatarUrl: text("avatar_url").default(""), + lastUpdated: text("last_updated") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + score: real("score").notNull().default(0), +}); + +export const userDailySummaries = sqliteTable( + "user_daily_summaries", + { + id: text("id").primaryKey(), // username_date + username: text("username").references(() => users.username), + date: text("date").notNull(), + score: real("score").notNull().default(0), + summary: text("summary").default(""), + totalCommits: integer("total_commits").notNull().default(0), + totalPRs: integer("total_prs").notNull().default(0), + additions: integer("additions").notNull().default(0), + deletions: integer("deletions").notNull().default(0), + changedFiles: integer("changed_files").notNull().default(0), + commits: text("commits").notNull().default("[]"), // JSON array of commits + pullRequests: text("pull_requests").notNull().default("[]"), // JSON array of PRs + issues: text("issues").notNull().default("[]"), // JSON array of issues + }, + (table) => ({ + usernameIdx: index("idx_user_daily_summaries_username").on(table.username), + dateIdx: index("idx_user_daily_summaries_date").on(table.date), + }) +); + +export const userStats = sqliteTable("user_stats", { + username: text("username") + .references(() => users.username) + .primaryKey(), + totalPRs: integer("total_prs").notNull().default(0), + mergedPRs: integer("merged_prs").notNull().default(0), + closedPRs: integer("closed_prs").notNull().default(0), + totalFiles: integer("total_files").notNull().default(0), + totalAdditions: integer("total_additions").notNull().default(0), + totalDeletions: integer("total_deletions").notNull().default(0), + filesByType: text("files_by_type").notNull().default("{}"), // JSON string + prsByMonth: text("prs_by_month").notNull().default("{}"), // JSON string + focusAreas: text("focus_areas").notNull().default("[]"), // JSON array of [area, count] tuples + lastUpdated: text("last_updated") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const tags = sqliteTable("tags", { + name: text("name").primaryKey(), + description: text("description").default(""), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastUpdated: text("last_updated") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const userTagScores = sqliteTable( + "user_tag_scores", + { + id: text("id").primaryKey(), // username_tag + username: text("username").references(() => users.username), + tag: text("tag").references(() => tags.name), + score: real("score").notNull().default(0), + level: integer("level").notNull().default(0), + progress: real("progress").notNull().default(0), + pointsToNext: real("points_to_next").notNull().default(0), + lastUpdated: text("last_updated") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + usernameIdx: index("idx_user_tag_scores_username").on(table.username), + }) +); diff --git a/src/lib/data/scoring.ts b/src/lib/data/scoring.ts new file mode 100644 index 0000000..cec3c37 --- /dev/null +++ b/src/lib/data/scoring.ts @@ -0,0 +1,329 @@ +import { + ContributorData, + PullRequestSchema, + CommitSchema, + PullRequestReviewSchema, + IssueSchema, + CommentSchema, +} from "@/lib/data/types"; +import type { z } from "zod"; +import fuzzysort from "fuzzysort"; + +/* +Test implementation of scoring logic in typescript. +*/ + +export interface ScoringConfig { + // PR scoring weights + baseMergedPRPoints: number; + prReviewPoints: number; + prApprovedReviewPoints: number; + prCommentPoints: number; + + // Impact multipliers + coreFileMultiplier: number; // Changes to core functionality + testFileMultiplier: number; // Test coverage improvements + docsFileMultiplier: number; // Documentation improvements + securityFixMultiplier: number; // Security-related changes + performanceFixMultiplier: number; // Performance improvements + accessibilityFixMultiplier: number; // Accessibility improvements + bugFixMultiplier: number; // Bug fixes + + // Review quality weights + inDepthReviewBonus: number; // Detailed code reviews + suggestionsBonus: number; // Helpful suggestions + mentorshipBonus: number; // Helping new contributors + + // Issue scoring weights + baseEngagedIssuePoints: number; + issueCommentPoints: number; + issueReferenceBonus: number; // Issue spawns multiple PRs + + // Commit scoring weights + baseCommitPoints: number; + + // Review scoring weights + reviewerPoints: number; + + // Volume scoring weights + volumeCommitPoints: number; + volumePRPoints: number; + volumeIssuePoints: number; + volumeCommentPoints: number; +} + +export const defaultScoringConfig: ScoringConfig = { + // PR scoring weights + baseMergedPRPoints: 7, + prReviewPoints: 3, + prApprovedReviewPoints: 2, + prCommentPoints: 0.5, + + // Impact multipliers + coreFileMultiplier: 2.0, + testFileMultiplier: 1.5, + docsFileMultiplier: 1.5, + securityFixMultiplier: 3.0, + performanceFixMultiplier: 2.0, + accessibilityFixMultiplier: 1.5, + bugFixMultiplier: 1.5, + + // Review quality weights + inDepthReviewBonus: 5, + suggestionsBonus: 3, + mentorshipBonus: 5, + + // Issue scoring weights + baseEngagedIssuePoints: 5, + issueCommentPoints: 0.5, + issueReferenceBonus: 5, + + // Commit scoring weights + baseCommitPoints: 1, + + // Review scoring weights + reviewerPoints: 5, + + // Volume scoring weights + volumeCommitPoints: 1, + volumePRPoints: 2, + volumeIssuePoints: 1, + volumeCommentPoints: 0.5, +}; + +type PR = z.infer; +type Commit = z.infer; +type Review = z.infer; +type Issue = z.infer; +type IssueComment = z.infer; + +const hasEngagement = (issue: Issue): boolean => { + const hasComments = (issue.comments?.length ?? 0) > 0; + const hasReactions = + issue.comments?.some( + (comment: IssueComment) => (comment.reactions?.length ?? 0) > 0 + ) ?? false; + return hasComments || hasReactions; +}; + +// Impact indicators with common variations and typos +const impactIndicators = { + security: ["security", "secure", "vulnerability", "vuln", "cve", "exploit"], + performance: [ + "performance", + "optimize", + "optimization", + "speed", + "fast", + "slow", + "perf", + ], + accessibility: ["accessibility", "a11y", "aria", "wcag", "screen reader"], + bugfix: ["fix", "bug", "issue", "problem", "error", "crash", "exception"], +} as const; + +const fuzzyMatch = (text: string, patterns: readonly string[]): boolean => { + // Prepare text for fuzzy search + const target = text.toLowerCase(); + + // Try to match any of the patterns + return patterns.some((pattern) => { + const result = fuzzysort.single(pattern, target); + // Check if we got a match and if it's a good match (score > -5000) + // fuzzysort scores range from 0 (perfect) to about -10000 (no match) + return result !== null && result.score > -5000; + }); +}; + +const calculateImpactMultiplier = (pr: PR, config: ScoringConfig): number => { + let multiplier = 1.0; + + // Check file types affected + const paths = pr.files?.map((f) => f.path) ?? []; + const hasCore = paths.some( + (f) => f.includes("src/core") || f.includes("src/lib") + ); + const hasTests = paths.some( + (f) => f.includes("test") || f.includes(".spec.") || f.includes(".test.") + ); + const hasDocs = paths.some( + (f) => f.includes("docs") || f.includes("README") || f.includes(".md") + ); + + // Apply multipliers + if (hasCore) multiplier *= config.coreFileMultiplier; + if (hasTests) multiplier *= config.testFileMultiplier; + if (hasDocs) multiplier *= config.docsFileMultiplier; + + // Check only PR title for impact indicators with fuzzy matching + const title = pr.title ?? ""; + + if (fuzzyMatch(title, impactIndicators.security)) { + multiplier *= config.securityFixMultiplier; + } + if (fuzzyMatch(title, impactIndicators.performance)) { + multiplier *= config.performanceFixMultiplier; + } + if (fuzzyMatch(title, impactIndicators.accessibility)) { + multiplier *= config.accessibilityFixMultiplier; + } + if (fuzzyMatch(title, impactIndicators.bugfix)) { + multiplier *= config.bugFixMultiplier; + } + + return multiplier; +}; + +const calculatePRPoints = (pr: PR, config: ScoringConfig): number => { + let points = 0; + + if (pr.merged) { + // Base points for merged PR + points += config.baseMergedPRPoints; + + // Points for reviews + points += (pr.reviews?.length ?? 0) * config.prReviewPoints; + + // Extra points for approved reviews + const approvedReviews = + pr.reviews?.filter((r) => r.state === "APPROVED").length ?? 0; + points += approvedReviews * config.prApprovedReviewPoints; + + // Apply impact multiplier + points *= calculateImpactMultiplier(pr, config); + } + + // Points for review comments + if (pr.comments?.length) { + points += pr.comments.length * config.prCommentPoints; + } + + return points; +}; + +const calculateReviewQuality = ( + review: Review, + pr: PR, + config: ScoringConfig +): number => { + let points = 0; + + // Check for in-depth review + if (review.body && review.body.length > 200) { + points += config.inDepthReviewBonus; + } + + // Check for helpful suggestions + if (review.body?.toLowerCase().includes("suggestion:")) { + points += config.suggestionsBonus; + } + + // Check for mentorship (helping new contributors) + const authorContributions = + (pr as { author_contributions_count?: number }) + .author_contributions_count ?? 0; + const isNewContributor = authorContributions <= 3; + if (isNewContributor && review.body && review.body.length > 100) { + points += config.mentorshipBonus; + } + + return points; +}; + +const calculateIssuePoints = (issue: Issue, config: ScoringConfig): number => { + let points = 0; + + if (hasEngagement(issue)) { + // Base points for engaged issues + points += config.baseEngagedIssuePoints; + + // Points for comments + const commentCount = issue.comments?.length ?? 0; + points += commentCount * config.issueCommentPoints; + + // Bonus for issues that spawn multiple PRs + const relatedPRs = + (issue as { related_prs?: PR[] }).related_prs?.length ?? 0; + if (relatedPRs > 0) { + points += config.issueReferenceBonus * relatedPRs; + } + } + + return points; +}; + +const calculateCommitPoints = ( + commit: Commit, + config: ScoringConfig +): number => { + const points = config.baseCommitPoints; + + // Add impact multiplier for commits + const multiplier = calculateImpactMultiplier( + { + number: 0, // Placeholder + title: commit.message ?? "", + state: "closed", + merged: true, + created_at: commit.created_at, + updated_at: commit.created_at, + body: commit.message ?? "", + files: [ + { + path: commit.sha, + additions: commit.additions, + deletions: commit.deletions, + }, + ], + reviews: [], + comments: [], + }, + config + ); + + return points * multiplier; +}; + +export const calculateScore = ( + contributor: ContributorData, + config: ScoringConfig = defaultScoringConfig +): number => { + let score = 0; + + // Calculate PR points + for (const pr of contributor.activity.code.pull_requests ?? []) { + score += calculatePRPoints(pr, config); + } + + // Calculate issue points + for (const issue of contributor.activity.issues?.opened ?? []) { + score += calculateIssuePoints(issue, config); + } + + // Calculate commit points + for (const commit of contributor.activity.code.commits ?? []) { + score += calculateCommitPoints(commit, config); + } + + // Points for being reviewer with quality metrics + for (const pr of contributor.activity.code.pull_requests ?? []) { + const reviews = + pr.reviews?.filter((r) => r.author === contributor.contributor) ?? []; + for (const review of reviews) { + score += + config.reviewerPoints + calculateReviewQuality(review, pr, config); + } + } + + // Base points for volume of activity + score += + (contributor.activity.code.total_commits ?? 0) * config.volumeCommitPoints; + score += (contributor.activity.code.total_prs ?? 0) * config.volumePRPoints; + score += + (contributor.activity.issues?.total_opened ?? 0) * config.volumeIssuePoints; + score += + (contributor.activity.engagement?.total_comments ?? 0) * + config.volumeCommentPoints; + + return Math.round(score); +}; diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts new file mode 100644 index 0000000..1910350 --- /dev/null +++ b/src/lib/data/types.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +export const CommitSchema = z.object({ + sha: z.string(), + message: z.string().optional().default(""), + created_at: z.string(), + additions: z.number().default(0), + deletions: z.number().default(0), + changed_files: z.number().default(0), +}); + +export const CommentSchema = z.object({ + id: z.string().optional(), + author: z.string(), + body: z.string().nullable().optional(), + reactions: z.array(z.string()).optional(), +}); + +export const PullRequestFileSchema = z.object({ + path: z.string(), + additions: z.number().default(0), + deletions: z.number().default(0), +}); + +export const PullRequestReviewSchema = z.object({ + author: z.string(), + state: z.string(), + body: z.string().nullable().optional(), +}); + +export const PullRequestSchema = z.object({ + number: z.number(), + title: z.string().optional().default(""), + state: z.string().optional().default("open"), + merged: z.boolean().optional().default(false), + created_at: z.string(), + updated_at: z.string().optional(), + body: z.string().nullable().optional(), + files: z.array(PullRequestFileSchema).optional(), + reviews: z.array(PullRequestReviewSchema).optional(), + comments: z.array(CommentSchema).optional(), +}); + +export const IssueLabelSchema = z.object({ + name: z.string(), + color: z.string().optional().default(""), + description: z.string().nullable().optional(), +}); + +export const IssueSchema = z.object({ + id: z.string().optional(), + number: z.number(), + title: z.string().optional().default(""), + body: z.string().nullable().optional(), + state: z + .string() + .transform((s) => s.toLowerCase()) + .optional() + .default("open"), + created_at: z.string(), + updated_at: z.string(), + author: z + .object({ + login: z.string(), + avatarUrl: z.string().nullable().optional(), + }) + .optional(), + labels: z.array(IssueLabelSchema).optional(), + comments: z.array(CommentSchema).optional(), +}); + +export const ContributorActivityCodeSchema = z.object({ + total_commits: z.number().default(0), + total_prs: z.number().default(0), + commits: z.array(CommitSchema).optional(), + pull_requests: z.array(PullRequestSchema).optional(), +}); + +export const ContributorActivityIssuesSchema = z.object({ + total_opened: z.number().default(0), + opened: z.array(IssueSchema).optional(), +}); + +export const ContributorActivityEngagementSchema = z.object({ + total_comments: z.number().default(0), + total_reviews: z.number().default(0), + comments: z.array(CommentSchema).optional(), + reviews: z.array(PullRequestReviewSchema).optional(), +}); + +export const ContributorDataSchema = z.object({ + contributor: z.string(), + score: z.number().default(0), + summary: z.string().optional().default(""), + avatar_url: z.string().nullable().optional(), + activity: z.object({ + code: ContributorActivityCodeSchema, + issues: ContributorActivityIssuesSchema.optional(), + engagement: ContributorActivityEngagementSchema.optional(), + }), +}); + +export const TagLevelSchema = z.object({ + level: z.number().default(0), + progress: z.number().default(0), + points: z.number().default(0), + points_next_level: z.number().default(0), +}); + +export const AnalysisStatsSchema = z.object({ + total_prs: z.number().default(0), + merged_prs: z.number().default(0), + closed_prs: z.number().default(0), + total_files: z.number().default(0), + total_additions: z.number().default(0), + total_deletions: z.number().default(0), + files_by_type: z.record(z.string(), z.number()).default({}), + prs_by_month: z.record(z.string(), z.number()).default({}), +}); + +export const AnalysisDataSchema = z.object({ + username: z.string(), + tag_scores: z.record(z.string(), z.number()).default({}), + tag_levels: z.record(z.string(), TagLevelSchema).default({}), + tags: z.array(z.string()).default([]), + stats: AnalysisStatsSchema, + focus_areas: z.array(z.tuple([z.string(), z.number()])).default([]), +}); + +export type ContributorData = z.infer; +export type AnalysisData = z.infer; diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index 0fb92f5..26be60f 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -48,9 +48,9 @@ export function denormalizeDate(date: string): string { } /** - * Extracts date from a filename in format "summary_2025_01_12.json" + * Extracts date from any filename containing a date pattern YYYY-MM-DD or YYYY_MM_DD */ export function extractDateFromFilename(filename: string): string | null { - const match = filename.match(/summary_(\d{4}[-_]\d{2}[-_]\d{2})\.json$/); - return match ? denormalizeDate(match[1]) : null; + const dateMatch = filename.match(/\d{4}[-_]\d{2}[-_]\d{2}/); + return dateMatch ? denormalizeDate(dateMatch[0]) : null; } diff --git a/tsconfig.json b/tsconfig.json index fe6dbb0..ad15e2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "scripts/**/*.ts" ], "exclude": ["node_modules"] }