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}
+
+
+
+
+
+ {matchReasons.length > 0 && (
+
+ {matchReasons.map((reason, idx) => (
+
+ ✓
+ {reason}
+
+ ))}
+
+ )}
+
+
+
+
+ ))}
+
+ )}
+
+
{/* 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 */}
+
+ setSelectedDifficulty(e.target.value)}
+ className="w-full rounded-lg border border-slate-800 bg-slate-900 px-3 py-2 text-xs text-slate-200 focus:border-sky-500 focus:outline-none font-mono capitalize"
+ >
+ All Difficulties
+ Beginner
+ Intermediate
+ Advanced
+
+
+
+ {/* Language / Skill Dropdown */}
+
+ setSelectedLanguage(e.target.value)}
+ className="w-full rounded-lg border border-slate-800 bg-slate-900 px-3 py-2 text-xs text-slate-200 focus:border-sky-500 focus:outline-none font-mono"
+ >
+ All Languages & Skills
+ {availableLanguages.map((lang) => (
+
+ {lang}
+
+ ))}
+
+
+
+
+ {/* Checkbox & Counter */}
+
+
+ setGoodFirstOnly(e.target.checked)}
+ className="rounded border-slate-700 bg-slate-800 text-sky-500 focus:ring-0 focus:ring-offset-0"
+ />
+
+ Only show good first issues
+
+
+
+
+ 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.
+
+
+ {
+ setSearch("");
+ setSelectedDifficulty("all");
+ setSelectedLanguage("all");
+ setGoodFirstOnly(false);
+ }}
+ className="text-xs font-mono text-sky-400 hover:text-sky-300 underline"
+ >
+ Reset all filters
+
+
+
+ ) : (
+
+ {filteredIssues.map((issue) => (
+
+
+
+
+ {issue.githubRepo}
+
+ {issue.isGoodFirstIssue && (
+
+ good first issue
+
+ )}
+ {issue.isHelpWanted && (
+
+ help wanted
+
+ )}
+
+ {issue.difficulty}
+
+
+
+
+
+ {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}
-
- ))}
-
- )}
-
-
-
-
- ))}
-
- )}
+
);
}
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() {
-
))}
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);
+}