Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added src/app/api/.gitkeep
Empty file.
96 changes: 96 additions & 0 deletions src/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { getGeminiModel } from "@/lib/gemini";
import { getRepoData, getRepoContents } from "@/lib/octokit";

// Ensure API keys are read at runtime
export const dynamic = "force-dynamic";

export async function POST(req: Request) {
// 1. Safe JSON Body Parsing
let rawUrl: string;
try {
const body = await req.json();
rawUrl = body.url;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

try {
// 2. Strict URL Validation
const trimmedUrl = rawUrl?.trim();
if (!trimmedUrl) {
return NextResponse.json({ error: "GitHub URL is required" }, { status: 400 });
}

let parsedUrl: URL;
try {
parsedUrl = new URL(trimmedUrl);
} catch {
return NextResponse.json({ error: "Please provide a valid URL" }, { status: 400 });
}

// Hostname Guard
if (parsedUrl.hostname !== "github.com" && parsedUrl.hostname !== "www.github.com") {
return NextResponse.json({ error: "Only GitHub URLs are supported" }, { status: 400 });
}

// Extract Owner and Repo from path
const pathSegments = parsedUrl.pathname.split("/").filter(Boolean);
const owner = pathSegments[0];
const repo = pathSegments[1];

if (!owner || !repo) {
return NextResponse.json({ error: "URL must include owner and repository name" }, { status: 400 });
}

// 3. Parallel Data Fetching
const [repoInfo, repoContents] = await Promise.all([
getRepoData(owner, repo),
getRepoContents(owner, repo)
]);

// 4. Type-Safe File Mapping (Fixes the 'any' linting error)
// We define the shape { name: string } inline to satisfy ESLint
const fileList = Array.isArray(repoContents) && repoContents.length > 0
? repoContents.map((f: { name: string }) => f.name).join(", ")
: "Standard repository structure";

// 5. Initialize Gemini 2.5
const model = getGeminiModel();

// 6. The "Expert Prompt" with Fallbacks
const prompt = `
You are an expert Technical Writer. Generate a professional README.md for:

Name: ${repo}
Description: ${repoInfo?.description || "A modern software project."}
Primary Language: ${repoInfo?.language || "Not specified"}
Root Directory Files: ${fileList}

Requirements:
- Include professional SVG badges from shields.io.
- Create a visual "Directory Structure" section (tree style).
- Include "Features", "Installation", and "Usage" sections.
- If 'package.json' exists, provide Node.js installation steps.
- Ensure a welcoming, professional developer-centric tone.

Return ONLY the Markdown content.
`;

// 7. AI Generation
const result = await model.generateContent(prompt);
const response = await result.response;
const markdown = response.text();

return NextResponse.json({ markdown });

} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Internal Server Error";
console.error("README Generation Failed:", message);

return NextResponse.json(
{ error: "Failed to generate README. Check your URL and try again." },
{ status: 500 }
);
}
}
31 changes: 24 additions & 7 deletions src/lib/octokit.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { Octokit } from "octokit";

// Private variable for Singleton pattern
let _octokit: Octokit | null = null;

export function getOctokit(): Octokit {
if (_octokit) return _octokit;

const auth = process.env.GITHUB_TOKEN;

// We don't throw an error here because Octokit can work without a token
// (unauthenticated), though it will be heavily rate-limited.
_octokit = new Octokit({
auth: auth || undefined,
});
Expand All @@ -18,8 +15,7 @@ export function getOctokit(): Octokit {
}

/**
* Fetches repository metadata safely.
* Notice we only log the error message, NOT the full error object.
* Fetches repository metadata (stars, description, language)
*/
export async function getRepoData(owner: string, repo: string) {
const client = getOctokit();
Expand All @@ -32,8 +28,29 @@ export async function getRepoData(owner: string, repo: string) {
return data;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "An unknown error occurred";
console.error("Error fetching GitHub repo:", message);

console.error("Error fetching GitHub repo metadata:", message);
return null;
}
}

/**
* NEW: Fetches the root contents to help Gemini build the File Structure
*/
export async function getRepoContents(owner: string, repo: string) {
const client = getOctokit();

try {
const { data } = await client.rest.repos.getContent({
owner,
repo,
path: "", // Root directory
});

// Return the array of files/folders
return Array.isArray(data) ? data : [];
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Could not fetch contents";
console.error("Error fetching GitHub repo contents:", message);
return [];
}
}
5 changes: 3 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
"@/*": ["./src/*"]
}
},
"include": [
Expand All @@ -31,4 +32,4 @@
"**/*.mts"
],
"exclude": ["node_modules"]
}
}