diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d442b1e..b546aa0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,8 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.1.1", "react-tooltip": "^5.28.0", - "tailwind-merge": "^3.2.0" + "tailwind-merge": "^3.2.0", + "toml": "^3.0.0" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -8394,6 +8395,11 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 34a0131..b4420dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,7 +42,8 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.1.1", "react-tooltip": "^5.28.0", - "tailwind-merge": "^3.2.0" + "tailwind-merge": "^3.2.0", + "toml": "^3.0.0" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/frontend/src/features/repositories/components/Dependencies/Dependencies.tsx b/frontend/src/features/repositories/components/Dependencies/Dependencies.tsx index fb37108..c0fbbde 100644 --- a/frontend/src/features/repositories/components/Dependencies/Dependencies.tsx +++ b/frontend/src/features/repositories/components/Dependencies/Dependencies.tsx @@ -33,7 +33,7 @@ const Dependencies = ({ repositoryDetails }: { repositoryDetails: RepositoryInfo className="h-full overflow-auto flex flex-col gap-3 w-full md:w-2/3 mb-3 overflow-x-hidden pr-1 min-h-96" > {dependenciesCheckIsAvailable === false ? ( - + ) : ( dependenciesList.map((dependency) => ( diff --git a/frontend/src/features/repositories/components/Dependencies/service.ts b/frontend/src/features/repositories/components/Dependencies/service.ts index 92b240c..0162016 100644 --- a/frontend/src/features/repositories/components/Dependencies/service.ts +++ b/frontend/src/features/repositories/components/Dependencies/service.ts @@ -1,6 +1,10 @@ import { extractRepositoryDetailsFromUrl } from "@/utils/general/url"; import { PackageDetails } from "./types"; import pLimit from "p-limit"; +import { fetchFileContent } from "../../services/repositories"; +import { ProjectType, RepositoryInfo } from "@/types/repository"; +import axios from "axios"; +import { parse } from "toml"; interface NpmsPackageDetails { collected: { @@ -72,13 +76,27 @@ const findPackageVersion = (dependencies: any, packageName: string): string | nu return null; }; -export const fetchPackagesSummary = async ({ - dependencies, - lockFileContent, +const fetchNodeProjectDependencies = async ({ + repositoryDetails, }: { - dependencies: Record; - lockFileContent: string; + repositoryDetails: RepositoryInfo; }): Promise => { + const packageFile = await fetchFileContent({ repositoryDetails, path: "package.json" }); + const packageLockFile = await fetchFileContent({ + repositoryDetails, + path: "package-lock.json", + }); + + if (!repositoryDetails || !packageFile) return []; + + let dependencies: Record = {}; + try { + dependencies = JSON.parse(packageFile).dependencies; + } catch (error) { + console.error("Error parsing package.json:", error); + } + const lockFileContent = packageLockFile ?? "{}"; + let lockDependencies: any = {}; try { lockDependencies = JSON.parse(lockFileContent)?.dependencies ?? {}; @@ -129,5 +147,343 @@ export const fetchPackagesSummary = async ({ }) ) ); + return formattedDependencies; }; + +const fetchPythonProjectDependencies = async ({ + repositoryDetails, +}: { + repositoryDetails: RepositoryInfo; +}): Promise => { + try { + // 1. Fetch dependency files + const requirementsFile = await fetchFileContent({ + repositoryDetails, + path: "requirements.txt", + }); + const pyProjectFile = await fetchFileContent({ repositoryDetails, path: "pyproject.toml" }); + + const packages: PackageDetails[] = []; + + // 2. Parse requirements.txt if exists + if (requirementsFile) { + const requirements = requirementsFile + .split("\n") + .filter((line) => line.trim() && !line.trim().startsWith("#")) + .map((line) => { + // Extract package name and version (handling cases like "package==1.0.0") + const match = line.trim().match(/^([a-zA-Z0-9_-]+)([=<>~!]=?.*)?$/); + return match + ? { name: match[1], version: match[2]?.replace(/^[=<>~!]=?/, "") || null } + : null; + }) + .filter(Boolean); + + for (const req of requirements) { + if (req) { + const { name, version } = req; + const pkgInfo = await getPyPIMetadata(name); + if (!pkgInfo) continue; // Skip if package metadata not found + + packages.push({ + name, + usedVersion: version, + latestVersion: pkgInfo?.version || null, + author: pkgInfo?.author || null, + organization: pkgInfo?.organization || null, + lastUpdate: pkgInfo?.last_update || null, + description: pkgInfo?.description || null, + dependencyScore: calculateDependencyScore(version, pkgInfo?.version), + }); + } + } + } + + // 3. Parse pyproject.toml if exists + if (pyProjectFile) { + try { + const pyProject = parse(pyProjectFile); + const deps = [ + ...(pyProject.tool?.poetry?.dependencies || []), + ...(pyProject.project?.dependencies || []), + ]; + + for (const [name, versionObj] of Object.entries(deps)) { + if (typeof name !== "string") continue; + + const version = typeof versionObj === "string" ? versionObj : versionObj?.version; + if (!packages.some((p) => p.name === name)) { + const pkgInfo = await getPyPIMetadata(name); + packages.push({ + name, + usedVersion: version, + latestVersion: pkgInfo?.version || null, + author: pkgInfo?.author || null, + organization: pkgInfo?.organization || null, + lastUpdate: pkgInfo?.last_update || null, + description: pkgInfo?.description || null, + dependencyScore: calculateDependencyScore(version, pkgInfo?.version), + }); + } + } + } catch (tomlError) { + console.error("Error parsing pyproject.toml:", tomlError); + } + } + + return packages; + } catch (error) { + console.error("Error fetching dependencies:", error); + return []; + } +}; + +async function getPyPIMetadata(packageName: string): Promise<{ + version?: string; + author?: string; + organization?: string; + last_update?: string; + description?: string; +} | null> { + try { + const response = await axios.get(`https://pypi.org/pypi/${packageName}/json`); + const info = response.data?.info; + return { + version: info?.version, + author: info?.author, + organization: info?.author_email?.split("@")[1]?.split(".")[0], // Extract domain from email + last_update: info?.release_date, + description: info?.summary, + }; + } catch (error) { + console.error(`Failed to fetch metadata for ${packageName}:`, error); + return null; + } +} + +function calculateDependencyScore( + usedVersion: string | null, + latestVersion: string | undefined +): number | null { + if (!usedVersion || !latestVersion) return null; + + try { + const usedParts = usedVersion.split(".").map(Number); + const latestParts = latestVersion.split(".").map(Number); + + let score = 100; + for (let i = 0; i < Math.min(usedParts.length, latestParts.length); i++) { + if (usedParts[i] < latestParts[i]) { + score -= 25 * (i + 1); + } + } + + return Math.max(0, score); + } catch { + return null; + } +} + +const fetchPhpProjectDependencies = async ({ + repositoryDetails, +}: { + repositoryDetails: RepositoryInfo; +}): Promise => { + try { + const composerContent = await fetchFileContent({ + repositoryDetails, + path: "composer.json", + }); + + if (!composerContent) return []; + + const composerJson = JSON.parse(composerContent) as { + require?: Record; + "require-dev"?: Record; + }; + const packages: PackageDetails[] = []; + + const allDependencies = { + ...(composerJson.require || {}), + ...(composerJson["require-dev"] || {}), + }; + + for (const [name, version] of Object.entries(allDependencies)) { + try { + const packagistResponse = await axios.get(`https://repo.packagist.org/p2/${name}.json`); + const packageData = packagistResponse.data.packages[name][0]; + + packages.push({ + name, + usedVersion: version.toString().replace(/^[\^~]/, ""), + latestVersion: packageData.version, + author: packageData.authors?.[0]?.name || null, + organization: name.split("/")[0] || null, + lastUpdate: packageData.time || null, + description: packageData.description || null, + dependencyScore: calculateDependencyScore(version.toString(), packageData.version), + }); + } catch (error) { + console.error(`Failed to fetch Packagist data for ${name}:`, error); + packages.push({ + name, + usedVersion: version.toString().replace(/^[\^~]/, ""), + latestVersion: null, + author: null, + organization: null, + lastUpdate: null, + description: null, + dependencyScore: null, + }); + } + } + + return packages; + } catch (error) { + console.error("Error processing PHP project dependencies:", error); + return []; + } +}; + +const fetchRustProjectDependencies = async ({ + repositoryDetails, +}: { + repositoryDetails: RepositoryInfo; +}): Promise => { + try { + const cargoContent = await fetchFileContent({ + repositoryDetails, + path: "Cargo.toml", + }); + + if (!cargoContent) return []; + + const cargoToml = parse(cargoContent) as { + dependencies?: Record; + }; + const packages: PackageDetails[] = []; + + for (const [name, spec] of Object.entries(cargoToml.dependencies || {})) { + try { + const version = typeof spec === "string" ? spec : spec.version; + const cratesResponse = await axios.get(`https://crates.io/api/v1/crates/${name}`); + const crateData = cratesResponse.data.crate; + + packages.push({ + name, + usedVersion: version?.replace(/^[\^~=]/, "") || null, + latestVersion: crateData.newest_version, + author: crateData.authors?.[0] || null, + organization: null, // Rust crates don't typically have organizations + lastUpdate: crateData.updated_at || null, + description: crateData.description || null, + dependencyScore: calculateDependencyScore(version, crateData.newest_version), + }); + } catch (error) { + console.error(`Failed to fetch crates.io data for ${name}:`, error); + packages.push({ + name, + usedVersion: + typeof spec === "string" ? spec.replace(/^[\^~=]/, "") : spec?.version || null, + latestVersion: null, + author: null, + organization: null, + lastUpdate: null, + description: null, + dependencyScore: null, + }); + } + } + + return packages; + } catch (error) { + console.error("Error processing Rust project dependencies:", error); + return []; + } +}; + +const fetchGoProjectDependencies = async ({ + repositoryDetails, +}: { + repositoryDetails: RepositoryInfo; +}): Promise => { + try { + const goModContent = await fetchFileContent({ + repositoryDetails, + path: "go.mod", + }); + + if (!goModContent) return []; + + const packages: PackageDetails[] = []; + const moduleLines = goModContent + .split("\n") + .filter((line) => line.startsWith("\t") && !line.includes("indirect")); + + for (const line of moduleLines) { + const [name, version] = line.trim().split(/\s+/); + try { + const proxyResponse = await axios.get(`https://proxy.golang.org/${name}/@v/list`); + const versions = proxyResponse.data.split("\n").filter(Boolean); + const latestVersion = versions[versions.length - 1]; + + packages.push({ + name, + usedVersion: version?.replace(/^v/, "") || null, + latestVersion: latestVersion?.replace(/^v/, "") || null, + author: null, // Go modules don't provide author info in the proxy + organization: name.split("/")[0] || null, + lastUpdate: null, // Would need to fetch from GitHub API + description: null, // Would need to fetch from GitHub API + dependencyScore: calculateDependencyScore(version, latestVersion), + }); + } catch (error) { + console.error(`Failed to fetch Go module data for ${name}:`, error); + packages.push({ + name, + usedVersion: version?.replace(/^v/, "") || null, + latestVersion: null, + author: null, + organization: null, + lastUpdate: null, + description: null, + dependencyScore: null, + }); + } + } + + return packages; + } catch (error) { + console.error("Error processing Go project dependencies:", error); + return []; + } +}; + +export const fetchPackagesSummary = async ({ + projectType, + repositoryDetails, +}: { + projectType: ProjectType; + repositoryDetails: RepositoryInfo; +}): Promise => { + switch (projectType) { + case "node": + return await fetchNodeProjectDependencies({ repositoryDetails }); + case "python": + return await fetchPythonProjectDependencies({ repositoryDetails }); + case "php": + return await fetchPhpProjectDependencies({ repositoryDetails }); + case "rust": + return await fetchRustProjectDependencies({ repositoryDetails }); + case "go": + return await fetchGoProjectDependencies({ repositoryDetails }); + case "c++": + case "unknown": + return []; + default: { + const _exhaustiveCheck: never = projectType; + return _exhaustiveCheck; + } + } +}; diff --git a/frontend/src/features/repositories/components/Dependencies/useFetchDependencies.tsx b/frontend/src/features/repositories/components/Dependencies/useFetchDependencies.tsx index 3def5d1..7827902 100644 --- a/frontend/src/features/repositories/components/Dependencies/useFetchDependencies.tsx +++ b/frontend/src/features/repositories/components/Dependencies/useFetchDependencies.tsx @@ -1,4 +1,3 @@ -import { fetchFileContent } from "@/features/repositories/services/repositories"; import { RepositoryInfo } from "@/types/repository"; import { useEffect, useState, useRef } from "react"; import { fetchPackagesSummary } from "./service"; @@ -15,33 +14,14 @@ export const useFetchDependencies = (repositoryDetails: RepositoryInfo | null) = if (fetchedRepoRef.current === repositoryDetails) return; setLoading(true); try { - const packageFile = await fetchFileContent({ repositoryDetails, path: "package.json" }); - const packageLockFile = await fetchFileContent({ + const scoredDependencies = await fetchPackagesSummary({ repositoryDetails, - path: "package-lock.json", + projectType: repositoryDetails.projectType, }); - if (packageFile) { - let dependencies = {}; - try { - dependencies = JSON.parse(packageFile).dependencies; - } catch (error) { - console.error("Error parsing package.json:", error); - } - const lockFileContent = packageLockFile ?? "{}"; - const scoredDependencies = await fetchPackagesSummary({ - dependencies, - lockFileContent, - }); - - setDependenciesList(scoredDependencies); - fetchedRepoRef.current = repositoryDetails; - setLoading(false); - return; - } - - setDependenciesList([]); + setDependenciesList(scoredDependencies); fetchedRepoRef.current = repositoryDetails; + setLoading(false); } catch (error) { setLoading(false); fetchedRepoRef.current = null; diff --git a/frontend/src/features/repositories/components/RepositoriesTable/types.ts b/frontend/src/features/repositories/components/RepositoriesTable/types.ts index b88c149..319ad2c 100644 --- a/frontend/src/features/repositories/components/RepositoriesTable/types.ts +++ b/frontend/src/features/repositories/components/RepositoriesTable/types.ts @@ -1,9 +1,11 @@ +import { ProjectType } from "@/types/repository"; + export interface NewRepositoryDetails { isValid: boolean; platform: string; organization: string; repository: string; - projectType: string; + projectType: ProjectType; defaultBranch: string; dependencyFile: string | null; readme: string | null; diff --git a/frontend/src/features/repositories/components/RepositoryCards/FolderStructureDisplay.tsx b/frontend/src/features/repositories/components/RepositoryCards/FolderStructureDisplay.tsx index 9d056e0..deda2bf 100644 --- a/frontend/src/features/repositories/components/RepositoryCards/FolderStructureDisplay.tsx +++ b/frontend/src/features/repositories/components/RepositoryCards/FolderStructureDisplay.tsx @@ -44,11 +44,11 @@ function renderTree( if (item.type === "tree") { return ( - + toggleFolder(fullPath)} > {isExpanded ? ( @@ -69,7 +69,7 @@ function renderTree( ); } else { return ( - + {item.path} diff --git a/frontend/src/types/repository.ts b/frontend/src/types/repository.ts index c5f424b..f26c3e1 100644 --- a/frontend/src/types/repository.ts +++ b/frontend/src/types/repository.ts @@ -34,12 +34,14 @@ export interface RepositoryInfo { createdAt: string; updatedAt: string; openIssues: number; - projectType: string; + projectType: ProjectType; readme: string | null; dependencyFile: string | null; lastUpdated?: string; } +export type ProjectType = "node" | "python" | "php" | "rust" | "go" | "c++" | "unknown"; + export interface TreeItem { path: string; type: "blob" | "tree";