Skip to content

Commit da227e0

Browse files
committed
feat(interviews-and-github): implement mock interviews and github auto-achievements
1 parent 55d3efc commit da227e0

7 files changed

Lines changed: 1195 additions & 95 deletions

File tree

src/actions/github-achievements.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"use server";
2+
3+
import Groq from "groq-sdk";
4+
import { auth } from "@/auth";
5+
import { createRateLimiter } from "@/lib/rate-limit";
6+
import type { ResumeData } from "@/db/schema";
7+
8+
// 5 AI calls per hour per user for github sync
9+
const aiRateLimiter = createRateLimiter({ limit: 5, windowSeconds: 3600 });
10+
11+
async function getAuthUserId(): Promise<string> {
12+
const session = await auth();
13+
if (!session?.user?.id) {
14+
throw new Error("Unauthorized");
15+
}
16+
return session.user.id;
17+
}
18+
19+
function getGroqClient(): Groq {
20+
const apiKey = process.env.GROQ_API_KEY;
21+
if (!apiKey) throw new Error("GROQ_API_KEY is not configured");
22+
return new Groq({ apiKey });
23+
}
24+
25+
function stripCodeFence(text: string): string {
26+
const trimmed = text.trim();
27+
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed;
28+
return trimmed
29+
.replace(/^```[a-zA-Z]*\n?/, "")
30+
.replace(/```$/, "")
31+
.trim();
32+
}
33+
34+
/**
35+
* Fetches top repositories for a GitHub user and uses AI to generate
36+
* STAR-method professional bullet points and structured project data.
37+
*/
38+
export async function generateGitHubAchievements(
39+
username: string,
40+
): Promise<{ result: ResumeData["projects"] }> {
41+
const userId = await getAuthUserId();
42+
43+
const rateCheck = await aiRateLimiter.check(userId);
44+
if (!rateCheck.allowed) {
45+
throw new Error(
46+
`Rate limit exceeded. Try again in ${rateCheck.retryAfterSeconds} seconds.`,
47+
);
48+
}
49+
50+
if (!username.trim()) throw new Error("GitHub username cannot be empty.");
51+
52+
// 1. Fetch from GitHub API
53+
// Using public endpoint, no token to avoid complexity, though rate limits apply
54+
const reposResponse = await fetch(
55+
`https://api.github.com/users/${username}/repos?sort=updated&per_page=100`,
56+
{
57+
headers: {
58+
Accept: "application/vnd.github.v3+json",
59+
"User-Agent": "Lab68-CV-Builder",
60+
},
61+
},
62+
);
63+
64+
if (!reposResponse.ok) {
65+
if (reposResponse.status === 404) {
66+
throw new Error("GitHub user not found.");
67+
}
68+
throw new Error(`GitHub API error: ${reposResponse.statusText}`);
69+
}
70+
71+
const allRepos = await reposResponse.json();
72+
73+
if (!Array.isArray(allRepos) || allRepos.length === 0) {
74+
throw new Error("No public repositories found for this user.");
75+
}
76+
77+
// Filter out forks and purely empty repos
78+
const sourceRepos = allRepos.filter((r) => !r.fork);
79+
80+
// Sort by stars descending, then get top 5
81+
const topRepos = sourceRepos
82+
.sort((a, b) => b.stargazers_count - a.stargazers_count)
83+
.slice(0, 5);
84+
85+
if (topRepos.length === 0) {
86+
throw new Error("No non-fork repositories found to analyze.");
87+
}
88+
89+
// 2. Prepare context for AI
90+
// We'll give it the repo name, description, primary language, topics, stars, and URL
91+
const repoDataText = topRepos.map((r) => ({
92+
name: r.name,
93+
description: r.description,
94+
language: r.language,
95+
topics: r.topics,
96+
stars: r.stargazers_count,
97+
url: r.html_url,
98+
homepage: r.homepage,
99+
}));
100+
101+
const groq = getGroqClient();
102+
103+
const prompt = `You are an expert technical recruiter and resume writer.
104+
I will provide you with data from a candidate's top GitHub repositories.
105+
Your task is to convert these repositories into professional resume projects.
106+
For each project, generate:
107+
- A concise, impactful description
108+
- 2 to 3 accomplishments/highlights written in the STAR method (Situation, Task, Action, Result). Make them sound highly impressive and action-oriented.
109+
- An array of technologies used (combine the primary language and topics).
110+
111+
Here is the raw GitHub data:
112+
${JSON.stringify(repoDataText, null, 2)}
113+
114+
Ensure you return ONLY a valid JSON array matching exactly this TypeScript signature:
115+
Array<{
116+
name: string;
117+
description: string; // concise 1-2 sentence description
118+
url: string; // The homepage url if available, or just leave empty string
119+
githubUrl: string; // The github html_url
120+
websiteUrl: string; // same as URL or homepage
121+
technologies: string[];
122+
highlights: string[]; // 2-3 impressive STAR method bullet points
123+
}>
124+
125+
No markdown formatting, no explanations, no text outside the JSON array.`;
126+
127+
const completion = await groq.chat.completions.create({
128+
model: "llama-3.3-70b-versatile",
129+
messages: [{ role: "user", content: prompt }],
130+
temperature: 0.5,
131+
});
132+
133+
const content = completion.choices[0]?.message?.content?.trim() || "[]";
134+
let parsedContent;
135+
136+
try {
137+
parsedContent = JSON.parse(stripCodeFence(content));
138+
if (!Array.isArray(parsedContent)) {
139+
throw new Error("Invalid format from AI");
140+
}
141+
} catch (error) {
142+
console.error("Parse error:", error, content);
143+
throw new Error("Failed to parse AI generated projects.");
144+
}
145+
146+
// Ensure every project has a unique ID
147+
const newProjects = parsedContent.map((proj) => ({
148+
id: crypto.randomUUID(),
149+
name: proj.name || "",
150+
description: proj.description || "",
151+
url: proj.url || proj.websiteUrl || proj.githubUrl || "",
152+
githubUrl: proj.githubUrl || "",
153+
websiteUrl: proj.websiteUrl || proj.url || "",
154+
technologies: proj.technologies || [],
155+
highlights: proj.highlights || [],
156+
}));
157+
158+
return { result: newProjects };
159+
}

0 commit comments

Comments
 (0)