299 lines
8.3 KiB
TypeScript
299 lines
8.3 KiB
TypeScript
/**
|
|
* GitHub Repository Analyzer
|
|
* Fetches and analyzes repository structure and key files for AI context
|
|
*/
|
|
|
|
import { getAdminDb } from '@/lib/firebase/admin';
|
|
|
|
interface RepositoryAnalysis {
|
|
repoFullName: string;
|
|
totalFiles: number;
|
|
fileStructure: {
|
|
directories: string[];
|
|
keyFiles: string[];
|
|
};
|
|
readme: string | null;
|
|
packageJson: Record<string, unknown> | null;
|
|
techStack: string[];
|
|
summary: string;
|
|
}
|
|
|
|
/**
|
|
* Analyze a GitHub repository to extract key information for AI context
|
|
*/
|
|
export async function analyzeGitHubRepository(
|
|
userId: string,
|
|
repoFullName: string,
|
|
branch = 'main'
|
|
): Promise<RepositoryAnalysis | null> {
|
|
try {
|
|
const adminDb = getAdminDb();
|
|
|
|
// Get GitHub access token
|
|
const connectionDoc = await adminDb
|
|
.collection('githubConnections')
|
|
.doc(userId)
|
|
.get();
|
|
|
|
if (!connectionDoc.exists) {
|
|
console.log('[GitHub Analyzer] No GitHub connection found');
|
|
return null;
|
|
}
|
|
|
|
const connection = connectionDoc.data()!;
|
|
const accessToken = connection.accessToken;
|
|
const [owner, repo] = repoFullName.split('/');
|
|
|
|
// Fetch repository tree
|
|
const treeResponse = await fetch(
|
|
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/vnd.github.v3+json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!treeResponse.ok) {
|
|
console.error('[GitHub Analyzer] Failed to fetch tree:', treeResponse.statusText);
|
|
return null;
|
|
}
|
|
|
|
const treeData = await treeResponse.json();
|
|
|
|
// Extract directories and key files
|
|
const directories = new Set<string>();
|
|
const keyFiles: string[] = [];
|
|
let totalFiles = 0;
|
|
|
|
treeData.tree?.forEach((item: { path: string; type: string }) => {
|
|
if (item.type === 'blob') {
|
|
totalFiles++;
|
|
|
|
// Track key files
|
|
const fileName = item.path.toLowerCase();
|
|
if (
|
|
fileName === 'readme.md' ||
|
|
fileName === 'package.json' ||
|
|
fileName === 'requirements.txt' ||
|
|
fileName === 'cargo.toml' ||
|
|
fileName === 'go.mod' ||
|
|
fileName === 'pom.xml' ||
|
|
fileName.startsWith('dockerfile')
|
|
) {
|
|
keyFiles.push(item.path);
|
|
}
|
|
}
|
|
|
|
// Track top-level directories
|
|
const parts = item.path.split('/');
|
|
if (parts.length > 1) {
|
|
directories.add(parts[0]);
|
|
}
|
|
});
|
|
|
|
// Fetch README content (truncate to first 3000 chars to avoid bloating prompts)
|
|
let readme: string | null = null;
|
|
const readmePath = keyFiles.find(f => f.toLowerCase().endsWith('readme.md'));
|
|
if (readmePath) {
|
|
const fullReadme = await fetchFileContent(accessToken, owner, repo, readmePath, branch);
|
|
if (fullReadme) {
|
|
// Truncate to first 3000 characters (roughly 750 tokens)
|
|
readme = fullReadme.length > 3000
|
|
? fullReadme.substring(0, 3000) + '\n\n[... README truncated for brevity ...]'
|
|
: fullReadme;
|
|
}
|
|
}
|
|
|
|
// Fetch package.json content
|
|
let packageJson: Record<string, unknown> | null = null;
|
|
const packageJsonPath = keyFiles.find(f => f.toLowerCase().endsWith('package.json'));
|
|
if (packageJsonPath) {
|
|
const content = await fetchFileContent(accessToken, owner, repo, packageJsonPath, branch);
|
|
if (content) {
|
|
try {
|
|
packageJson = JSON.parse(content);
|
|
} catch (e) {
|
|
console.error('[GitHub Analyzer] Failed to parse package.json');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detect tech stack
|
|
const techStack = detectTechStack(keyFiles, Array.from(directories), packageJson);
|
|
|
|
// Generate summary
|
|
const summary = generateRepositorySummary({
|
|
repoFullName,
|
|
totalFiles,
|
|
directories: Array.from(directories),
|
|
keyFiles,
|
|
techStack,
|
|
readme,
|
|
packageJson,
|
|
});
|
|
|
|
return {
|
|
repoFullName,
|
|
totalFiles,
|
|
fileStructure: {
|
|
directories: Array.from(directories).slice(0, 20), // Limit to top 20
|
|
keyFiles,
|
|
},
|
|
readme: readme ? readme.substring(0, 2000) : null, // First 2000 chars
|
|
packageJson,
|
|
techStack,
|
|
summary,
|
|
};
|
|
} catch (error) {
|
|
console.error('[GitHub Analyzer] Error analyzing repository:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch file content from GitHub
|
|
*/
|
|
async function fetchFileContent(
|
|
accessToken: string,
|
|
owner: string,
|
|
repo: string,
|
|
path: string,
|
|
branch: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const response = await fetch(
|
|
`https://api.github.com/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}?ref=${branch}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/vnd.github.v3+json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) return null;
|
|
|
|
const data = await response.json();
|
|
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
} catch (error) {
|
|
console.error(`[GitHub Analyzer] Failed to fetch ${path}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect tech stack from repository structure
|
|
*/
|
|
function detectTechStack(
|
|
keyFiles: string[],
|
|
directories: string[],
|
|
packageJson: Record<string, unknown> | null
|
|
): string[] {
|
|
const stack: string[] = [];
|
|
|
|
// From key files
|
|
if (keyFiles.some(f => f.toLowerCase().includes('package.json'))) {
|
|
stack.push('Node.js/JavaScript');
|
|
|
|
if (packageJson) {
|
|
const deps = {
|
|
...(packageJson.dependencies as Record<string, unknown> || {}),
|
|
...(packageJson.devDependencies as Record<string, unknown> || {})
|
|
};
|
|
|
|
if (deps.next) stack.push('Next.js');
|
|
if (deps.react) stack.push('React');
|
|
if (deps.vue) stack.push('Vue');
|
|
if (deps.express) stack.push('Express');
|
|
if (deps.typescript) stack.push('TypeScript');
|
|
}
|
|
}
|
|
|
|
if (keyFiles.some(f => f.toLowerCase().includes('requirements.txt') || f.toLowerCase().includes('pyproject.toml'))) {
|
|
stack.push('Python');
|
|
}
|
|
|
|
if (keyFiles.some(f => f.toLowerCase().includes('cargo.toml'))) {
|
|
stack.push('Rust');
|
|
}
|
|
|
|
if (keyFiles.some(f => f.toLowerCase().includes('go.mod'))) {
|
|
stack.push('Go');
|
|
}
|
|
|
|
if (keyFiles.some(f => f.toLowerCase().includes('pom.xml') || f.toLowerCase().includes('build.gradle'))) {
|
|
stack.push('Java');
|
|
}
|
|
|
|
if (keyFiles.some(f => f.toLowerCase().startsWith('dockerfile'))) {
|
|
stack.push('Docker');
|
|
}
|
|
|
|
// From directories
|
|
if (directories.includes('.github')) stack.push('GitHub Actions');
|
|
if (directories.includes('terraform') || directories.includes('infrastructure')) {
|
|
stack.push('Infrastructure as Code');
|
|
}
|
|
|
|
return stack;
|
|
}
|
|
|
|
/**
|
|
* Generate a human-readable summary
|
|
*/
|
|
function generateRepositorySummary(analysis: {
|
|
repoFullName: string;
|
|
totalFiles: number;
|
|
directories: string[];
|
|
keyFiles: string[];
|
|
techStack: string[];
|
|
readme: string | null;
|
|
packageJson: Record<string, unknown> | null;
|
|
}): string {
|
|
const parts: string[] = [];
|
|
|
|
parts.push(`## Repository Analysis: ${analysis.repoFullName}`);
|
|
parts.push(`\n**Structure:**`);
|
|
parts.push(`- Total files: ${analysis.totalFiles}`);
|
|
|
|
if (analysis.directories.length > 0) {
|
|
parts.push(`- Main directories: ${analysis.directories.slice(0, 15).join(', ')}`);
|
|
}
|
|
|
|
if (analysis.techStack.length > 0) {
|
|
parts.push(`\n**Tech Stack:** ${analysis.techStack.join(', ')}`);
|
|
}
|
|
|
|
if (analysis.packageJson) {
|
|
const pkg = analysis.packageJson;
|
|
parts.push(`\n**Package Info:**`);
|
|
if (pkg.name) parts.push(`- Name: ${pkg.name}`);
|
|
if (pkg.description) parts.push(`- Description: ${pkg.description}`);
|
|
if (pkg.version) parts.push(`- Version: ${pkg.version}`);
|
|
|
|
// Show key dependencies
|
|
const deps = pkg.dependencies as Record<string, string> || {};
|
|
const devDeps = pkg.devDependencies as Record<string, string> || {};
|
|
const allDeps = { ...deps, ...devDeps };
|
|
const keyDeps = Object.keys(allDeps).slice(0, 10);
|
|
if (keyDeps.length > 0) {
|
|
parts.push(`- Key dependencies: ${keyDeps.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
if (analysis.readme) {
|
|
parts.push(`\n**README Content:**`);
|
|
// Get first few paragraphs or up to 1000 chars
|
|
const readmeExcerpt = analysis.readme.substring(0, 1000);
|
|
parts.push(readmeExcerpt);
|
|
if (analysis.readme.length > 1000) {
|
|
parts.push('...(truncated)');
|
|
}
|
|
}
|
|
|
|
return parts.join('\n');
|
|
}
|
|
|