From bbf6d989f068370314aae04b1eceb21b8a155f9a Mon Sep 17 00:00:00 2001 From: ashishsinghbora <135435891+ashishsinghbora@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:42:53 +0530 Subject: [PATCH] feat: implement Issue Marketplace and deterministic recommendation engine (Phase 3) --- src/app/api/sync/issues/route.ts | 35 ++++ src/app/dashboard/page.tsx | 137 ++++++++++++++ src/app/issues/IssueMarketplace.tsx | 238 +++++++++++++++++++++++++ src/app/issues/page.tsx | 104 +++-------- src/app/projects/page.tsx | 30 +++- src/lib/github/issues.test.ts | 42 +++++ src/lib/github/issues.ts | 221 +++++++++++++++++++++++ src/lib/recommendations/engine.test.ts | 105 +++++++++++ src/lib/recommendations/engine.ts | 117 ++++++++++++ 9 files changed, 939 insertions(+), 90 deletions(-) create mode 100644 src/app/api/sync/issues/route.ts create mode 100644 src/app/issues/IssueMarketplace.tsx create mode 100644 src/lib/github/issues.test.ts create mode 100644 src/lib/github/issues.ts create mode 100644 src/lib/recommendations/engine.test.ts create mode 100644 src/lib/recommendations/engine.ts diff --git a/src/app/api/sync/issues/route.ts b/src/app/api/sync/issues/route.ts new file mode 100644 index 0000000..257b800 --- /dev/null +++ b/src/app/api/sync/issues/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; +import { syncRepositoryIssues } from "@/lib/github/issues"; + +export async function POST(request: NextRequest) { + const token = request.cookies.get(COOKIE_NAME)?.value; + const user = token ? await verifySessionToken(token) : null; + + // Verify privileged role or internal bearer secret + const authHeader = request.headers.get("authorization"); + const isAuthorizedSecret = + authHeader && process.env.AUTH_SECRET && authHeader === `Bearer ${process.env.AUTH_SECRET}`; + + if (!isAuthorizedSecret && (!user || (user.role !== "admin" && user.role !== "maintainer"))) { + return NextResponse.json({ error: "Unauthorized. Admin or maintainer privileges required." }, { status: 403 }); + } + + try { + const body = await request.json().catch(() => ({})); + const owner = body.owner || "TechNexusOrg"; + const repo = body.repo || "platform"; + + const result = await syncRepositoryIssues(owner, repo); + + return NextResponse.json({ + success: true, + repository: `${owner}/${repo}`, + syncedCount: result.syncedCount, + rateLimitRemaining: result.rateLimitRemaining, + }); + } catch (error: any) { + console.error("Issue sync error:", error); + return NextResponse.json({ error: error.message || "Failed to sync issues" }, { status: 500 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index cff7fb3..3d38679 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; import { evaluateProgression } from "@/lib/progression/rules"; +import { recommendIssues } from "@/lib/recommendations/engine"; export const metadata = { title: "Contributor Dashboard — TechNexusOrg", @@ -62,6 +63,46 @@ export default async function DashboardPage() { isOnboarded: sessionUser.isOnboarded, }); + // Fetch open issues for recommendations + const openIssues = await db + .select({ + id: schema.issues.id, + projectId: schema.issues.projectId, + projectName: schema.projects.name, + githubRepo: schema.projects.githubRepo, + title: schema.issues.title, + bodySnippet: schema.issues.bodySnippet, + htmlUrl: schema.issues.htmlUrl, + labels: schema.issues.labels, + difficulty: schema.issues.difficulty, + estimatedEffort: schema.issues.estimatedEffort, + skillsRequired: schema.issues.skillsRequired, + isGoodFirstIssue: schema.issues.isGoodFirstIssue, + isHelpWanted: schema.issues.isHelpWanted, + primaryLanguage: schema.projects.primaryLanguage, + }) + .from(schema.issues) + .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) + .where(eq(schema.issues.state, "open")) + .limit(50); + + const recommended = profile + ? recommendIssues( + openIssues.map((i: any) => ({ + ...i, + difficulty: i.difficulty as "beginner" | "intermediate" | "advanced", + })), + { + skills: profile.skills || [], + interests: profile.interests || [], + experienceLevel: (profile.experienceLevel as any) || "beginner", + preferredLanguages: profile.preferredLanguages || [], + contributionPreferences: profile.contributionPreferences || [], + }, + 4 + ) + : []; + return (
{/* Contributor Header */} @@ -178,6 +219,102 @@ export default async function DashboardPage() {
+ {/* Recommended Issues Section */} +
+
+
+

+ Recommended Contributions For You +

+

+ Deterministic matching based on your skills, experience tier, and interests. +

+
+ + Explore All Issues → + +
+ + {recommended.length === 0 ? ( +
+

+ No matching issues currently open. +

+

+ Browse the contribution marketplace or check official TechNexusOrg repositories on GitHub. +

+ + Browse Issues Marketplace → + +
+ ) : ( +
+ {recommended.map(({ issue, score, matchReasons }) => ( +
+
+
+ + {issue.githubRepo} + + + Match Score: {score} + +
+ +

+ + {issue.title} + +

+ + {matchReasons.length > 0 && ( +
+ {matchReasons.map((reason, idx) => ( +
+ ✓ + {reason} +
+ ))} +
+ )} +
+ +
+ + Difficulty: {issue.difficulty} + + + View on GitHub → + +
+
+ ))} +
+ )} +
+ {/* Verified Credentials Section */}
diff --git a/src/app/issues/IssueMarketplace.tsx b/src/app/issues/IssueMarketplace.tsx new file mode 100644 index 0000000..f35927f --- /dev/null +++ b/src/app/issues/IssueMarketplace.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useState, useMemo } from "react"; + +export interface MarketplaceIssue { + id: string; + title: string; + bodySnippet: string | null; + htmlUrl: string; + labels: string[]; + difficulty: "beginner" | "intermediate" | "advanced"; + estimatedEffort: string | null; + skillsRequired: string[]; + isGoodFirstIssue: boolean; + isHelpWanted: boolean; + projectName: string; + githubRepo: string; + primaryLanguage: string; +} + +export function IssueMarketplace({ initialIssues }: { initialIssues: MarketplaceIssue[] }) { + const [search, setSearch] = useState(""); + const [selectedDifficulty, setSelectedDifficulty] = useState("all"); + const [selectedLanguage, setSelectedLanguage] = useState("all"); + const [goodFirstOnly, setGoodFirstOnly] = useState(false); + + // Extract unique languages from issues + const availableLanguages = useMemo(() => { + const langs = new Set(); + for (const issue of initialIssues) { + if (issue.primaryLanguage) langs.add(issue.primaryLanguage); + for (const skill of issue.skillsRequired) { + langs.add(skill); + } + } + return Array.from(langs).sort(); + }, [initialIssues]); + + const filteredIssues = useMemo(() => { + return initialIssues.filter((issue) => { + // Search text match + if (search.trim()) { + const query = search.toLowerCase(); + const matchesTitle = issue.title.toLowerCase().includes(query); + const matchesBody = issue.bodySnippet?.toLowerCase().includes(query); + const matchesRepo = issue.githubRepo.toLowerCase().includes(query); + const matchesSkills = issue.skillsRequired.some((s) => s.toLowerCase().includes(query)); + if (!matchesTitle && !matchesBody && !matchesRepo && !matchesSkills) { + return false; + } + } + + // Difficulty filter + if (selectedDifficulty !== "all" && issue.difficulty !== selectedDifficulty) { + return false; + } + + // Language filter + if (selectedLanguage !== "all") { + const matchesLang = + issue.primaryLanguage.toLowerCase() === selectedLanguage.toLowerCase() || + issue.skillsRequired.some((s) => s.toLowerCase() === selectedLanguage.toLowerCase()); + if (!matchesLang) return false; + } + + // Good first issue only + if (goodFirstOnly && !issue.isGoodFirstIssue) { + return false; + } + + return true; + }); + }, [initialIssues, search, selectedDifficulty, selectedLanguage, goodFirstOnly]); + + return ( +
+ {/* Filter Toolbar */} +
+
+ {/* Search Input */} +
+ setSearch(e.target.value)} + placeholder="Search by keyword, skill, or repo..." + className="w-full rounded-lg border border-slate-800 bg-slate-900 px-3 py-2 text-xs text-white placeholder-slate-500 focus:border-sky-500 focus:outline-none font-mono" + /> +
+ + {/* Difficulty Dropdown */} +
+ +
+ + {/* Language / Skill Dropdown */} +
+ +
+
+ + {/* Checkbox & Counter */} +
+ + +
+ Showing {filteredIssues.length} of {initialIssues.length} issue(s) +
+
+
+ + {/* Issues List */} + {filteredIssues.length === 0 ? ( +
+
No matching issues found
+

+ Try adjusting your search filters, or view all official open-source repositories on GitHub. +

+
+ +
+
+ ) : ( +
+ {filteredIssues.map((issue) => ( +
+
+
+ + {issue.githubRepo} + + {issue.isGoodFirstIssue && ( + + good first issue + + )} + {issue.isHelpWanted && ( + + help wanted + + )} + + {issue.difficulty} + +
+ +

+ + {issue.title} + +

+ + {issue.bodySnippet && ( +

+ {issue.bodySnippet} +

+ )} + + {issue.skillsRequired.length > 0 && ( +
+ {issue.skillsRequired.map((skill) => ( + + {skill} + + ))} +
+ )} +
+ +
+ + View Issue + ↗ + + {issue.estimatedEffort && ( + + Est: {issue.estimatedEffort} + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index 1a1a0b6..ee21443 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -1,5 +1,6 @@ import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; +import { IssueMarketplace, type MarketplaceIssue } from "./IssueMarketplace"; export const dynamic = "force-dynamic"; @@ -16,22 +17,41 @@ export default async function IssuesPage() { .select({ id: schema.issues.id, title: schema.issues.title, + bodySnippet: schema.issues.bodySnippet, htmlUrl: schema.issues.htmlUrl, labels: schema.issues.labels, difficulty: schema.issues.difficulty, estimatedEffort: schema.issues.estimatedEffort, skillsRequired: schema.issues.skillsRequired, isGoodFirstIssue: schema.issues.isGoodFirstIssue, + isHelpWanted: schema.issues.isHelpWanted, projectName: schema.projects.name, githubRepo: schema.projects.githubRepo, + primaryLanguage: schema.projects.primaryLanguage, }) .from(schema.issues) .innerJoin(schema.projects, eq(schema.issues.projectId, schema.projects.id)) .where(eq(schema.issues.state, "open")) - .limit(30); + .limit(100); + + const formattedIssues: MarketplaceIssue[] = openIssues.map((issue: any) => ({ + id: issue.id, + title: issue.title, + bodySnippet: issue.bodySnippet, + htmlUrl: issue.htmlUrl, + labels: issue.labels || [], + difficulty: issue.difficulty as "beginner" | "intermediate" | "advanced", + estimatedEffort: issue.estimatedEffort, + skillsRequired: issue.skillsRequired || [], + isGoodFirstIssue: issue.isGoodFirstIssue, + isHelpWanted: issue.isHelpWanted, + projectName: issue.projectName, + githubRepo: issue.githubRepo, + primaryLanguage: issue.primaryLanguage || "TypeScript", + })); return ( -
+

Contribution Marketplace @@ -41,85 +61,7 @@ export default async function IssuesPage() {

- {openIssues.length === 0 ? ( -
-
- Issue Pipeline -
-

- No Open Issues Synced Yet -

-

- As official repositories are registered and issues tagged with good first issue or help wanted are created on GitHub, they will appear here automatically via webhooks. -

- -
- ) : ( -
- {openIssues.map((issue: any) => ( -
-
-
- - {issue.githubRepo} - - {issue.isGoodFirstIssue && ( - - good first issue - - )} - - {issue.difficulty} - -
-

- {issue.title} -

- {issue.skillsRequired && issue.skillsRequired.length > 0 && ( -
- {issue.skillsRequired.map((skill: string) => ( - - {skill} - - ))} -
- )} -
- -
- - View on GitHub → - - {issue.estimatedEffort && ( - - Est: {issue.estimatedEffort} - - )} -
-
- ))} -
- )} +
); } diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 6b9b3fd..e439fcb 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,5 +1,6 @@ import { getDb, schema } from "@/lib/db"; import { eq } from "drizzle-orm"; +import Link from "next/link"; export const dynamic = "force-dynamic"; @@ -75,15 +76,26 @@ export default async function ProjectsPage() {

-
- - GitHub Repository → - +
+ + {project.openIssuesCount || 0} open issue(s) + +
+ + View Issues + + + GitHub ↗ + +
))} diff --git a/src/lib/github/issues.test.ts b/src/lib/github/issues.test.ts new file mode 100644 index 0000000..497b739 --- /dev/null +++ b/src/lib/github/issues.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { parseIssueLabels } from "./issues"; + +describe("GitHub Issue Label Parser", () => { + it("parses beginner good-first-issue correctly", () => { + const meta = parseIssueLabels([ + "good first issue", + "help wanted", + "python", + "docker", + "effort: 1-2 hours", + ]); + + expect(meta.difficulty).toBe("beginner"); + expect(meta.isGoodFirstIssue).toBe(true); + expect(meta.isHelpWanted).toBe(true); + expect(meta.estimatedEffort).toBe("1-2 hours"); + expect(meta.skillsRequired).toContain("Python"); + expect(meta.skillsRequired).toContain("Docker"); + }); + + it("parses advanced issue and assigns default effort", () => { + const meta = parseIssueLabels([ + { name: "advanced" }, + { name: "rust" }, + { name: "linux" }, + ]); + + expect(meta.difficulty).toBe("advanced"); + expect(meta.isGoodFirstIssue).toBe(false); + expect(meta.skillsRequired).toContain("Rust"); + expect(meta.skillsRequired).toContain("Linux"); + expect(meta.estimatedEffort).toBe("1–2 days"); + }); + + it("parses intermediate difficulty with medium tag", () => { + const meta = parseIssueLabels(["medium", "typescript", "react"]); + expect(meta.difficulty).toBe("intermediate"); + expect(meta.skillsRequired).toContain("TypeScript"); + expect(meta.skillsRequired).toContain("React"); + }); +}); diff --git a/src/lib/github/issues.ts b/src/lib/github/issues.ts new file mode 100644 index 0000000..dec6c9a --- /dev/null +++ b/src/lib/github/issues.ts @@ -0,0 +1,221 @@ +import { getGitHubClient, checkRateLimit } from "./client"; +import { getDb, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; + +export interface ParsedIssueMetadata { + difficulty: "beginner" | "intermediate" | "advanced"; + estimatedEffort: string | null; + skillsRequired: string[]; + isGoodFirstIssue: boolean; + isHelpWanted: boolean; +} + +const KNOWN_SKILLS = [ + "TypeScript", + "JavaScript", + "Python", + "Go", + "Rust", + "React", + "Next.js", + "Node.js", + "PostgreSQL", + "Docker", + "Linux", + "Git", + "Tailwind", + "CI/CD", + "GraphQL", + "REST", +]; + +export function parseIssueLabels(labels: Array<{ name?: string } | string>): ParsedIssueMetadata { + const labelNames = labels + .map((l) => (typeof l === "string" ? l : l.name || "")) + .filter(Boolean); + + const lowerLabels = labelNames.map((l) => l.toLowerCase()); + + let difficulty: "beginner" | "intermediate" | "advanced" = "beginner"; + if (lowerLabels.some((l) => l.includes("advanced") || l.includes("hard") || l.includes("complex"))) { + difficulty = "advanced"; + } else if (lowerLabels.some((l) => l.includes("intermediate") || l.includes("medium"))) { + difficulty = "intermediate"; + } else if (lowerLabels.some((l) => l.includes("beginner") || l.includes("easy") || l.includes("good first"))) { + difficulty = "beginner"; + } + + const isGoodFirstIssue = lowerLabels.some( + (l) => l.includes("good first issue") || l.includes("good-first-issue") + ); + const isHelpWanted = lowerLabels.some( + (l) => l.includes("help wanted") || l.includes("help-wanted") + ); + + // Extract estimated effort if labeled, e.g. "effort: 2-4 hours" + let estimatedEffort: string | null = null; + const effortLabel = labelNames.find((l) => l.toLowerCase().startsWith("effort:")); + if (effortLabel) { + estimatedEffort = effortLabel.split(":")[1]?.trim() || null; + } else if (difficulty === "beginner") { + estimatedEffort = "1–3 hours"; + } else if (difficulty === "intermediate") { + estimatedEffort = "3–8 hours"; + } else { + estimatedEffort = "1–2 days"; + } + + // Extract skills from labels + const skillsRequired: string[] = []; + for (const skill of KNOWN_SKILLS) { + if (lowerLabels.some((l) => l.includes(skill.toLowerCase()))) { + skillsRequired.push(skill); + } + } + + return { + difficulty, + estimatedEffort, + skillsRequired, + isGoodFirstIssue, + isHelpWanted, + }; +} + +export async function syncRepositoryIssues( + owner: string, + repo: string, + userToken?: string +): Promise<{ syncedCount: number; rateLimitRemaining?: number }> { + const octokit = getGitHubClient(userToken); + const rateLimit = await checkRateLimit(octokit); + + if (rateLimit && rateLimit.remaining < 5) { + throw new Error( + `GitHub API rate limit exhausted. Resets at ${rateLimit.resetDate.toISOString()}` + ); + } + + // 1. Fetch repository metadata + const { data: repoData } = await octokit.rest.repos.get({ + owner, + repo, + }); + + const db = await getDb(); + const repoFullName = repoData.full_name; + + // Upsert project + const existingProjects = await db + .select() + .from(schema.projects) + .where(eq(schema.projects.githubRepo, repoFullName)) + .limit(1); + + let projectId: string; + if (existingProjects.length > 0) { + projectId = existingProjects[0].id; + await db + .update(schema.projects) + .set({ + name: repoData.name, + description: repoData.description || "Official TechNexusOrg repository", + primaryLanguage: repoData.language || "TypeScript", + starsCount: repoData.stargazers_count, + forksCount: repoData.forks_count, + openIssuesCount: repoData.open_issues_count, + updatedAt: new Date(), + }) + .where(eq(schema.projects.id, projectId)); + } else { + projectId = `proj_${crypto.randomUUID()}`; + await db.insert(schema.projects).values({ + id: projectId, + name: repoData.name, + slug: repoData.name.toLowerCase(), + githubRepo: repoFullName, + description: repoData.description || "Official TechNexusOrg repository", + primaryLanguage: repoData.language || "TypeScript", + languages: repoData.language ? [repoData.language] : [], + isOfficial: true, + starsCount: repoData.stargazers_count, + forksCount: repoData.forks_count, + openIssuesCount: repoData.open_issues_count, + }); + } + + // 2. Fetch issues (excluding pull requests) + const { data: issuesData } = await octokit.rest.issues.listForRepo({ + owner, + repo, + state: "open", + per_page: 50, + }); + + let syncedCount = 0; + + for (const ghIssue of issuesData) { + // GitHub API includes pull requests in issues endpoint; filter them out + if (ghIssue.pull_request) { + continue; + } + + const metadata = parseIssueLabels(ghIssue.labels); + const labelStrings = ghIssue.labels + .map((l) => (typeof l === "string" ? l : l.name || "")) + .filter(Boolean); + + // If no explicit skills found in labels, add primary language + const finalSkills = + metadata.skillsRequired.length > 0 + ? metadata.skillsRequired + : repoData.language + ? [repoData.language] + : []; + + const existingIssue = await db + .select() + .from(schema.issues) + .where(eq(schema.issues.githubIssueId, ghIssue.id)) + .limit(1); + + if (existingIssue.length > 0) { + await db + .update(schema.issues) + .set({ + title: ghIssue.title, + bodySnippet: ghIssue.body ? ghIssue.body.substring(0, 300) : null, + state: ghIssue.state === "closed" ? "closed" : "open", + labels: labelStrings, + difficulty: metadata.difficulty, + estimatedEffort: metadata.estimatedEffort, + skillsRequired: finalSkills, + isGoodFirstIssue: metadata.isGoodFirstIssue, + isHelpWanted: metadata.isHelpWanted, + updatedAt: new Date(), + }) + .where(eq(schema.issues.id, existingIssue[0].id)); + } else { + await db.insert(schema.issues).values({ + id: `iss_${crypto.randomUUID()}`, + projectId, + githubIssueId: ghIssue.id, + githubIssueNumber: ghIssue.number, + title: ghIssue.title, + bodySnippet: ghIssue.body ? ghIssue.body.substring(0, 300) : null, + state: "open", + htmlUrl: ghIssue.html_url, + labels: labelStrings, + difficulty: metadata.difficulty, + estimatedEffort: metadata.estimatedEffort, + skillsRequired: finalSkills, + isGoodFirstIssue: metadata.isGoodFirstIssue, + isHelpWanted: metadata.isHelpWanted, + }); + } + + syncedCount++; + } + + return { syncedCount, rateLimitRemaining: rateLimit?.remaining }; +} diff --git a/src/lib/recommendations/engine.test.ts b/src/lib/recommendations/engine.test.ts new file mode 100644 index 0000000..2461f86 --- /dev/null +++ b/src/lib/recommendations/engine.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { recommendIssues, type IssueCandidate, type UserPreferences } from "./engine"; + +describe("Deterministic Issue Recommendation Engine", () => { + const mockIssues: IssueCandidate[] = [ + { + id: "iss_python_easy", + projectId: "proj_1", + projectName: "Python CLI Tools", + githubRepo: "TechNexusOrg/python-tools", + title: "Add unit tests for argument parser", + bodySnippet: "Write pytest cases for cli args", + htmlUrl: "https://github.com/TechNexusOrg/python-tools/issues/1", + labels: ["good first issue", "python", "beginner"], + difficulty: "beginner", + estimatedEffort: "1–3 hours", + skillsRequired: ["Python", "Git"], + isGoodFirstIssue: true, + isHelpWanted: true, + primaryLanguage: "Python", + }, + { + id: "iss_rust_hard", + projectId: "proj_2", + projectName: "Core Engine", + githubRepo: "TechNexusOrg/engine", + title: "Optimize SIMD vector operations", + bodySnippet: "SIMD instruction dispatch", + htmlUrl: "https://github.com/TechNexusOrg/engine/issues/99", + labels: ["advanced", "rust", "simd"], + difficulty: "advanced", + estimatedEffort: "2–4 days", + skillsRequired: ["Rust"], + isGoodFirstIssue: false, + isHelpWanted: false, + primaryLanguage: "Rust", + }, + { + id: "iss_ts_web", + projectId: "proj_3", + projectName: "Web Platform", + githubRepo: "TechNexusOrg/platform", + title: "Implement issue filtering controls", + bodySnippet: "Add dropdown filters for language and effort", + htmlUrl: "https://github.com/TechNexusOrg/platform/issues/25", + labels: ["typescript", "react", "intermediate"], + difficulty: "intermediate", + estimatedEffort: "3–5 hours", + skillsRequired: ["TypeScript", "React"], + isGoodFirstIssue: false, + isHelpWanted: true, + primaryLanguage: "TypeScript", + }, + ]; + + it("prioritizes beginner Python good-first-issue for a beginner Python learner", () => { + const preferences: UserPreferences = { + skills: ["Python"], + interests: ["CLI Tools"], + experienceLevel: "beginner", + preferredLanguages: ["Python"], + contributionPreferences: ["good first issue"], + }; + + const recommended = recommendIssues(mockIssues, preferences); + + expect(recommended.length).toBeGreaterThan(0); + expect(recommended[0].issue.id).toBe("iss_python_easy"); + expect(recommended[0].score).toBeGreaterThan(recommended[1].score); + expect(recommended[0].matchReasons).toContain("Targeted for beginner level"); + expect(recommended[0].matchReasons).toContain("Tagged as 'good first issue'"); + expect(recommended[0].matchReasons.some((r) => r.includes("Python"))).toBe(true); + }); + + it("prioritizes intermediate TypeScript issue for an intermediate frontend engineer", () => { + const preferences: UserPreferences = { + skills: ["TypeScript", "React"], + interests: ["Web Platform"], + experienceLevel: "intermediate", + preferredLanguages: ["TypeScript"], + contributionPreferences: ["features"], + }; + + const recommended = recommendIssues(mockIssues, preferences); + + expect(recommended[0].issue.id).toBe("iss_ts_web"); + expect(recommended[0].matchReasons.some((r) => r.includes("TypeScript"))).toBe(true); + expect(recommended[0].matchReasons).toContain("Targeted for intermediate level"); + }); + + it("ranks advanced Rust issue top for an advanced systems developer", () => { + const preferences: UserPreferences = { + skills: ["Rust"], + interests: ["Engine"], + experienceLevel: "advanced", + preferredLanguages: ["Rust"], + contributionPreferences: ["performance"], + }; + + const recommended = recommendIssues(mockIssues, preferences); + + expect(recommended[0].issue.id).toBe("iss_rust_hard"); + expect(recommended[0].matchReasons).toContain("Targeted for advanced level"); + }); +}); diff --git a/src/lib/recommendations/engine.ts b/src/lib/recommendations/engine.ts new file mode 100644 index 0000000..91fc47b --- /dev/null +++ b/src/lib/recommendations/engine.ts @@ -0,0 +1,117 @@ +export interface IssueCandidate { + id: string; + projectId: string; + projectName: string; + githubRepo: string; + title: string; + bodySnippet: string | null; + htmlUrl: string; + labels: string[]; + difficulty: "beginner" | "intermediate" | "advanced"; + estimatedEffort: string | null; + skillsRequired: string[]; + isGoodFirstIssue: boolean; + isHelpWanted: boolean; + primaryLanguage: string; +} + +export interface UserPreferences { + skills: string[]; + interests: string[]; + experienceLevel: "beginner" | "intermediate" | "advanced"; + preferredLanguages: string[]; + contributionPreferences: string[]; +} + +export interface RecommendedIssue { + issue: IssueCandidate; + score: number; + matchReasons: string[]; +} + +export function recommendIssues( + issues: IssueCandidate[], + preferences: UserPreferences, + limit = 10 +): RecommendedIssue[] { + const userSkillsLower = new Set(preferences.skills.map((s) => s.toLowerCase())); + const userInterestsLower = new Set(preferences.interests.map((i) => i.toLowerCase())); + const userLangsLower = new Set(preferences.preferredLanguages.map((l) => l.toLowerCase())); + + const scored: RecommendedIssue[] = []; + + for (const issue of issues) { + let score = 0; + const matchReasons: string[] = []; + + // 1. Difficulty & Experience Level Alignment (Max 25 pts) + if (issue.difficulty === preferences.experienceLevel) { + score += 25; + matchReasons.push(`Targeted for ${preferences.experienceLevel} level`); + } else if ( + preferences.experienceLevel === "intermediate" && + issue.difficulty === "beginner" + ) { + score += 15; + matchReasons.push("Quick win for intermediate level"); + } + + // 2. Beginner Good-First-Issue Bonus (20 pts) + if (preferences.experienceLevel === "beginner" && issue.isGoodFirstIssue) { + score += 20; + matchReasons.push("Tagged as 'good first issue'"); + } + + // 3. Primary Language Match (20 pts) + if ( + userLangsLower.has(issue.primaryLanguage.toLowerCase()) || + userSkillsLower.has(issue.primaryLanguage.toLowerCase()) + ) { + score += 20; + matchReasons.push(`Primary repository language matches '${issue.primaryLanguage}'`); + } + + // 4. Skills Match (10 pts per matching skill, max 30 pts) + const matchingSkills: string[] = []; + for (const skill of issue.skillsRequired) { + if (userSkillsLower.has(skill.toLowerCase())) { + matchingSkills.push(skill); + } + } + if (matchingSkills.length > 0) { + const skillScore = Math.min(30, matchingSkills.length * 10); + score += skillScore; + matchReasons.push(`Matches your skills: ${matchingSkills.join(", ")}`); + } + + // 5. Help Wanted Flag (10 pts) + if (issue.isHelpWanted) { + score += 10; + matchReasons.push("Actively seeking community maintainer help"); + } + + // 6. Interest Alignment (10 pts) + for (const interest of userInterestsLower) { + if ( + issue.projectName.toLowerCase().includes(interest) || + issue.title.toLowerCase().includes(interest) || + (issue.bodySnippet && issue.bodySnippet.toLowerCase().includes(interest)) + ) { + score += 10; + matchReasons.push(`Aligns with your interest in '${interest}'`); + break; + } + } + + scored.push({ + issue, + score, + matchReasons: matchReasons.length > 0 ? matchReasons : ["Open for contribution"], + }); + } + + // Sort descending by score, tiebreak by id + scored.sort((a, b) => b.score - a.score || a.issue.id.localeCompare(b.issue.id)); + + return scored.slice(0, limit); +}