diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..222115b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [refacs*, main] + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - run: npm run compile + + - run: npm test --if-present + + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - run: npx @vscode/vsce publish + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} diff --git a/.vscode/launch.json b/.vscode/launch.json index ed8dc8a..d6a059a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "outFiles": [ "${workspaceFolder}/out/**/*.js" ], - "preLaunchTask": "${workspaceFolder}/npm: compile" + "preLaunchTask": "npm: compile" } ] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f698ec --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Git4Neo + +[![CI](https://github.com/aycsi/git4neo/actions/workflows/ci.yml/badge.svg)](https://github.com/aycsi/git4neo/actions/workflows/ci.yml) + +Analyze Git repositories as a Neo4j knowledge graph. Connect one or many repos, then explore contributors, dependencies, code complexity, and cross-repo relationships through graph queries and built-in visualizations. + +## Setup + +1. Install the extension +2. Have a Neo4j instance running +3. Run **Git4Neo: Setup** from the command palette to configure your connection +4. You'll also need a GitHub token for fetching repository data + +## Configuration +Settings are under `git4neo.*` in VS Code: + +- `neo4jUri` -- bolt URI (default: `bolt://localhost:7687`) +- `neo4jUsername` -- default: `neo4j` +- `neo4jPassword` -- your Neo4j password +- `githubToken` -- GitHub personal access token diff --git a/icon.png b/icon.png index 8c941c9..3736d3b 100644 Binary files a/icon.png and b/icon.png differ diff --git a/package-lock.json b/package-lock.json index bd4adab..28edb93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git4neo", - "version": "0.0.6", + "version": "0.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git4neo", - "version": "0.0.6", + "version": "0.0.9", "dependencies": { "neo4j-driver": "^5.15.0", "octokit": "^3.1.2", @@ -3330,9 +3330,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3410,12 +3410,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -4051,9 +4051,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4311,9 +4311,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", - "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -4323,7 +4323,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, diff --git a/package.json b/package.json index 492e0c4..1034f53 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,29 @@ { "name": "git4neo", "displayName": "Git4Neo", - "description": "A comprehensive tool to get repository data towards Neo4j", - "version": "0.0.7", + "description": "Analyze Git repositories as a Neo4j knowledge graph", + "version": "0.1.0", "publisher": "aycsi", "icon": "icon.png", "keywords": [ "neo4j", "git", - "repository", "graph", - "database", - "github", - "data-science" + "analytics", + "code-analysis", + "repository", + "visualization", + "contributors", + "dependencies", + "github" ], "engines": { "vscode": "^1.74.0" }, "categories": [ "Data Science", - "Other" + "Visualization", + "SCM Providers" ], "activationEvents": [ "onCommand:git4neo.testExtension", @@ -27,10 +31,30 @@ "onCommand:git4neo.connectMultipleRepositories", "onCommand:git4neo.viewGraph", "onCommand:git4neo.openBatchManager", - "onCommand:git4neo.runQuery" + "onCommand:git4neo.runQuery", + "onCommand:git4neo.setupWizard", + "onCommand:git4neo.crossRepoAnalysis", + "onCommand:git4neo.exportInsights" ], "main": "./out/extension.js", "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "git4neo", + "title": "Git4Neo", + "icon": "icon.png" + } + ] + }, + "views": { + "git4neo": [ + { + "id": "git4neo.insights", + "name": "Insights" + } + ] + }, "commands": [ { "command": "git4neo.testExtension", @@ -55,6 +79,27 @@ { "command": "git4neo.runQuery", "title": "Git4Neo: Run Cypher Query" + }, + { + "command": "git4neo.setupWizard", + "title": "Git4Neo: Setup Wizard" + }, + { + "command": "git4neo.refreshInsights", + "title": "Git4Neo: Refresh Insights", + "icon": "$(refresh)" + }, + { + "command": "git4neo.selectRepo", + "title": "Git4Neo: Select Repository" + }, + { + "command": "git4neo.crossRepoAnalysis", + "title": "Git4Neo: Cross-Repo Analysis" + }, + { + "command": "git4neo.exportInsights", + "title": "Git4Neo: Export Insights" } ], "configuration": { @@ -82,6 +127,18 @@ } }, "menus": { + "view/title": [ + { + "command": "git4neo.refreshInsights", + "when": "view == git4neo.insights", + "group": "navigation" + }, + { + "command": "git4neo.selectRepo", + "when": "view == git4neo.insights", + "group": "navigation" + } + ], "explorer/context": [ { "command": "git4neo.connectRepository", @@ -122,13 +179,62 @@ "when": "true" } ] - } + }, + "walkthroughs": [ + { + "id": "git4neo.getStarted", + "title": "Get Started with Git4Neo", + "description": "Turn your Git repositories into a Neo4j knowledge graph", + "steps": [ + { + "id": "setupNeo4j", + "title": "Connect to Neo4j", + "description": "Run the setup wizard to configure your Neo4j connection.\n\n[Run Setup Wizard](command:git4neo.setupWizard)", + "media": { + "markdown": "" + } + }, + { + "id": "connectRepo", + "title": "Analyze Your First Repository", + "description": "Connect a GitHub repository to Neo4j for graph-based analysis.\n\n[Connect Repository](command:git4neo.connectRepository)", + "media": { + "markdown": "" + } + }, + { + "id": "viewGraph", + "title": "Explore the Graph", + "description": "Open Neo4j Browser to visualize your repository data as a graph.\n\n[View Graph](command:git4neo.viewGraph)", + "media": { + "markdown": "" + } + }, + { + "id": "runQuery", + "title": "Run a Cypher Query", + "description": "Query your repository graph directly with Cypher.\n\n[Run Query](command:git4neo.runQuery)", + "media": { + "markdown": "" + } + }, + { + "id": "batchAnalysis", + "title": "Analyze Multiple Repositories", + "description": "Use the Batch Manager to process many repos at once.\n\n[Open Batch Manager](command:git4neo.openBatchManager)", + "media": { + "markdown": "" + } + } + ] + } + ] }, "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", - "test": "jest" + "test": "jest --passWithNoTests" }, "devDependencies": { "@types/jest": "^29.0.0", diff --git a/src/extension.ts b/src/extension.ts index d07195c..6dd0dbd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,13 +4,40 @@ import { GitHubService } from './services/githubService'; import { RepositoryAnalyzer } from './services/repositoryAnalyzer'; import { BatchProcessor } from './services/batchProcessor'; import { BatchManagerView } from './views/batchManager'; +import { InsightsPanel } from './views/insightsPanel'; +import { GraphView } from './views/graphView'; export function activate(context: vscode.ExtensionContext) { + try { return doActivate(context); } catch (e) { + vscode.window.showErrorMessage(`Git4Neo activation failed: ${e instanceof Error ? e.message : String(e)}`); + } +} + +function doActivate(context: vscode.ExtensionContext) { const neo4jService = new Neo4jService(); const githubService = new GitHubService(); const repositoryAnalyzer = new RepositoryAnalyzer(neo4jService, githubService); const batchProcessor = new BatchProcessor(neo4jService, githubService, repositoryAnalyzer); const batchManagerView = new BatchManagerView(batchProcessor); + const insightsPanel = new InsightsPanel(neo4jService); + + vscode.window.registerTreeDataProvider('git4neo.insights', insightsPanel); + + const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + statusBar.command = 'git4neo.testExtension'; + context.subscriptions.push(statusBar); + + const updStatus = () => { + if (neo4jService.connected) { + statusBar.text = '$(database) Neo4j: Connected'; + statusBar.tooltip = 'Git4Neo - Connected to Neo4j'; + } else { + statusBar.text = '$(circle-slash) Neo4j: Disconnected'; + statusBar.tooltip = 'Git4Neo - Not connected. Click to check status.'; + } + statusBar.show(); + }; + updStatus(); const testExtension = vscode.commands.registerCommand('git4neo.testExtension', async () => { try { @@ -70,6 +97,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showInformationMessage('Repository connected to Neo4j'); } catch (error) { vscode.window.showErrorMessage(`Failed to connect repository: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); } }); @@ -77,13 +106,9 @@ export function activate(context: vscode.ExtensionContext) { batchManagerView.show(); }); - const viewGraph = vscode.commands.registerCommand('git4neo.viewGraph', async (uri?: vscode.Uri) => { - try { - const browserUri = await neo4jService.getNeo4jBrowserUri(); - await vscode.env.openExternal(vscode.Uri.parse(browserUri)); - } catch (error) { - vscode.window.showErrorMessage(`Failed to open Neo4j browser: ${error instanceof Error ? error.message : String(error)}`); - } + const graphView = new GraphView(neo4jService); + const viewGraph = vscode.commands.registerCommand('git4neo.viewGraph', async () => { + await graphView.show(); }); const openBatchManager = vscode.commands.registerCommand('git4neo.openBatchManager', (uri?: vscode.Uri) => { @@ -105,11 +130,167 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showInformationMessage(`Query returned ${results.length} results`); } catch (error) { vscode.window.showErrorMessage(`Query failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); + } + } + }); + + const setupWizard = vscode.commands.registerCommand('git4neo.setupWizard', async () => { + const cfg = vscode.workspace.getConfiguration('git4neo'); + + const uri = await vscode.window.showInputBox({ + prompt: '1/4 Neo4j URI', + value: cfg.get('neo4jUri', 'bolt://localhost:7687'), + placeHolder: 'bolt://localhost:7687' + }); + if (!uri) { return; } + + const user = await vscode.window.showInputBox({ + prompt: '2/4 Neo4j Username', + value: cfg.get('neo4jUsername', 'neo4j'), + placeHolder: 'neo4j' + }); + if (!user) { return; } + + const pw = await vscode.window.showInputBox({ + prompt: '3/4 Neo4j Password', + password: true, + placeHolder: 'Enter your Neo4j password' + }); + if (pw === undefined) { return; } + + const ghToken = await vscode.window.showInputBox({ + prompt: '4/4 GitHub Token (optional, press Enter to skip)', + value: cfg.get('githubToken', ''), + placeHolder: 'ghp_...' + }); + + await cfg.update('neo4jUri', uri, vscode.ConfigurationTarget.Global); + await cfg.update('neo4jUsername', user, vscode.ConfigurationTarget.Global); + await cfg.update('neo4jPassword', pw, vscode.ConfigurationTarget.Global); + if (ghToken) { + await cfg.update('githubToken', ghToken, vscode.ConfigurationTarget.Global); + } + + try { + await neo4jService.connect(); + vscode.window.showInformationMessage('Setup complete - Neo4j connection verified!'); + } catch (error) { + vscode.window.showErrorMessage(`Settings saved but connection failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); + } + }); + + const refreshInsights = vscode.commands.registerCommand('git4neo.refreshInsights', () => { + insightsPanel.refresh(); + }); + + const selectRepo = vscode.commands.registerCommand('git4neo.selectRepo', async () => { + try { + await neo4jService.connect(); + const repos = await neo4jService.executeQuery('MATCH (r:Repository) RETURN r.fullName as name ORDER BY r.fullName'); + if (repos.length === 0) { + vscode.window.showInformationMessage('No repositories found. Connect a repository first.'); + return; + } + const pick = await vscode.window.showQuickPick( + repos.map((r: any) => r.name), + { placeHolder: 'Select a repository' } + ); + if (pick) { + insightsPanel.setRepo(pick); } + } catch (error) { + vscode.window.showErrorMessage(`Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); + } + }); + + const crossRepoAnalysis = vscode.commands.registerCommand('git4neo.crossRepoAnalysis', async () => { + try { + const result = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Running cross-repo analysis...', + cancellable: false + }, async (progress) => { + return await repositoryAnalyzer.analyzeCrossRepo(progress); + }); + vscode.window.showInformationMessage( + `Cross-repo analysis complete: ${result.deps} shared deps, ${result.contribs} shared contributors, ${result.langs} shared languages` + ); + } catch (error) { + vscode.window.showErrorMessage(`Cross-repo analysis failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); + } + }); + + const exportInsights = vscode.commands.registerCommand('git4neo.exportInsights', async () => { + try { + await neo4jService.connect(); + const repos = await neo4jService.executeQuery('MATCH (r:Repository) RETURN r.fullName as name ORDER BY r.fullName'); + if (repos.length === 0) { + vscode.window.showInformationMessage('No repositories found.'); + return; + } + const pick = await vscode.window.showQuickPick( + repos.map((r: any) => r.name), + { placeHolder: 'Select repository to export' } + ); + if (!pick) { return; } + + const fmt = await vscode.window.showQuickPick(['JSON', 'CSV'], { placeHolder: 'Export format' }); + if (!fmt) { return; } + + const p = { repositoryId: pick }; + const { Neo4jQueryService } = await import('./services/neo4jQueryService'); + const data: Record = {}; + data.hotspots = await neo4jService.executeQuery(Neo4jQueryService.QUERIES.HOTSPOTS, p); + data.contributors = await neo4jService.executeQuery(Neo4jQueryService.QUERIES.TOP_CONTRIBUTORS, p); + data.dependencies = await neo4jService.executeQuery(Neo4jQueryService.QUERIES.DEPENDENCY_GRAPH, p); + data.codeQuality = await neo4jService.executeQuery(Neo4jQueryService.QUERIES.CODE_QUALITY_SUMMARY, p); + data.recentCommits = await neo4jService.executeQuery(Neo4jQueryService.QUERIES.COMMIT_TRENDS, p); + + let content: string; + let ext: string; + if (fmt === 'JSON') { + content = JSON.stringify({ repository: pick, exportedAt: new Date().toISOString(), ...data }, null, 2); + ext = 'json'; + } else { + const sections: string[] = []; + for (const [section, rows] of Object.entries(data)) { + if (rows.length === 0) { continue; } + const keys = Object.keys(rows[0]); + sections.push(`# ${section}`); + sections.push(keys.join(',')); + for (const row of rows) { + sections.push(keys.map(k => String(row[k] ?? '').replace(/,/g, ';')).join(',')); + } + sections.push(''); + } + content = sections.join('\n'); + ext = 'csv'; + } + + const uri = await vscode.window.showSaveDialog({ + filters: { [fmt]: [ext] }, + defaultUri: vscode.Uri.file(`git4neo-export-${pick.replace('/', '-')}.${ext}`) + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8')); + vscode.window.showInformationMessage(`Exported insights for ${pick}`); + } + } catch (error) { + vscode.window.showErrorMessage(`Export failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + updStatus(); } }); - context.subscriptions.push(testExtension, connectRepository, connectMultipleRepositories, viewGraph, openBatchManager, runQuery); + context.subscriptions.push(testExtension, connectRepository, connectMultipleRepositories, viewGraph, openBatchManager, runQuery, setupWizard, refreshInsights, selectRepo, crossRepoAnalysis, exportInsights); } export function deactivate() {} diff --git a/src/services/batchProcessor.ts b/src/services/batchProcessor.ts index bf91901..33a857f 100644 --- a/src/services/batchProcessor.ts +++ b/src/services/batchProcessor.ts @@ -33,8 +33,6 @@ export interface BatchJob { export class BatchProcessor { private jobs: Map = new Map(); private isProcessing = false; - private activeConnections = 0; - private maxConnections = 10; constructor( private neo4jService: Neo4jService, @@ -88,9 +86,10 @@ export class BatchProcessor { job.status = 'running'; job.startTime = new Date(); + let connectionEstablished = false; try { await this.neo4jService.connect(); - this.activeConnections++; + connectionEstablished = true; const batchSize = job.config.batchSize; const totalBatches = Math.ceil(job.repositories.length / batchSize); @@ -108,15 +107,18 @@ export class BatchProcessor { } const start = i * batchSize; - const end = Math.min(start + batchSize, job.repositories.length); - const batch = job.repositories.slice(start, end); + const end = Math.min(start + batchSize, currentJob.repositories.length); + const batch = currentJob.repositories.slice(start, end); + const batchProgress = ((i + 1) / totalBatches) * 100; + currentJob.progress = Math.min(batchProgress, 100); + progress?.report({ increment: (100 / totalBatches), message: `Processing batch ${i + 1}/${totalBatches} (${batch.length} repositories)` }); - await this.processBatchConcurrently(batch, job, progress); + await this.processBatchConcurrently(batch, currentJob, progress); // Check memory usage and pause if needed if (this.isMemoryUsageHigh()) { @@ -125,21 +127,24 @@ export class BatchProcessor { } } - if (job.status === 'running') { - job.status = 'completed'; - job.progress = 100; - job.endTime = new Date(); + const finalJob = this.jobs.get(jobId); + if (finalJob && finalJob.status === 'running') { + finalJob.status = 'completed'; + finalJob.progress = 100; + finalJob.endTime = new Date(); } } catch (error) { - job.status = 'failed'; - job.errors.push(error instanceof Error ? error.message : String(error)); + const errorJob = this.jobs.get(jobId); + if (errorJob) { + errorJob.status = 'failed'; + errorJob.errors.push(error instanceof Error ? error.message : String(error)); + } throw error; } finally { this.isProcessing = false; - if (this.activeConnections > 0) { + if (connectionEstablished) { await this.neo4jService.disconnect(); - this.activeConnections--; } } } @@ -156,7 +161,13 @@ export class BatchProcessor { try { progress?.report({ message: `Analyzing ${repoUrl}...` }); - await this.repositoryAnalyzer.analyzeRepository(repoUrl, job.config); + const analysisConfig = { + maxFileSize: job.config.maxFileSize, + enableStreaming: job.config.enableStreaming, + batchSize: job.config.batchSize + }; + + await this.repositoryAnalyzer.analyzeRepository(repoUrl, analysisConfig, progress, true); job.results.successRepos++; job.results.processedRepos++; diff --git a/src/services/dependencyAnalyzer.ts b/src/services/dependencyAnalyzer.ts index 725419f..eb6caab 100644 --- a/src/services/dependencyAnalyzer.ts +++ b/src/services/dependencyAnalyzer.ts @@ -8,36 +8,141 @@ export interface DependencyInfo { } export class DependencyAnalyzer { + async analyzeDeps(repoPath: string): Promise { + const results: DependencyInfo[] = []; + results.push(...await this.analyzePackageJson(repoPath)); + results.push(...this.parseReqsTxt(repoPath)); + results.push(...this.parsePyproject(repoPath)); + results.push(...this.parseGoMod(repoPath)); + results.push(...this.parseCargoToml(repoPath)); + results.push(...this.parseGemfile(repoPath)); + return results; + } + async analyzePackageJson(repoPath: string): Promise { - const packageJsonPath = path.join(repoPath, 'package.json'); - - if (!fs.existsSync(packageJsonPath)) { - return []; - } - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const dependencies: DependencyInfo[] = []; - - if (packageJson.dependencies) { - for (const [name, version] of Object.entries(packageJson.dependencies)) { - dependencies.push({ - name, - version: version as string, - type: 'dependency' - }); - } - } - - if (packageJson.devDependencies) { - for (const [name, version] of Object.entries(packageJson.devDependencies)) { - dependencies.push({ - name, - version: version as string, - type: 'devDependency' - }); - } - } - - return dependencies; + const filePath = path.join(repoPath, 'package.json'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const deps: DependencyInfo[] = []; + + for (const [name, version] of Object.entries(pkg.dependencies || {})) { + deps.push({ name, version: version as string, type: 'dependency' }); + } + for (const [name, version] of Object.entries(pkg.devDependencies || {})) { + deps.push({ name, version: version as string, type: 'devDependency' }); + } + for (const [name, version] of Object.entries(pkg.peerDependencies || {})) { + deps.push({ name, version: version as string, type: 'peerDependency' }); + } + return deps; + } catch { return []; } + } + + private parseReqsTxt(repoPath: string): DependencyInfo[] { + const filePath = path.join(repoPath, 'requirements.txt'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + const deps: DependencyInfo[] = []; + for (const raw of lines) { + const l = raw.trim(); + if (!l || l.startsWith('#') || l.startsWith('-')) { continue; } + const m = l.match(/^([a-zA-Z0-9_.-]+)\s*(?:[><=!~]+\s*(.+))?/); + if (m) { deps.push({ name: m[1], version: m[2]?.trim() || '*', type: 'dependency' }); } + } + return deps; + } catch { return []; } + } + + private parsePyproject(repoPath: string): DependencyInfo[] { + const filePath = path.join(repoPath, 'pyproject.toml'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const deps: DependencyInfo[] = []; + const depBlock = content.match(/\[project\][\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/); + if (depBlock) { + const entries = depBlock[1].match(/"([^"]+)"/g) || []; + for (const entry of entries) { + const raw = entry.replace(/"/g, ''); + const m = raw.match(/^([a-zA-Z0-9_.-]+)\s*(?:[><=!~]+\s*(.+))?/); + if (m) { deps.push({ name: m[1], version: m[2]?.trim() || '*', type: 'dependency' }); } + } + } + return deps; + } catch { return []; } + } + + private parseGoMod(repoPath: string): DependencyInfo[] { + const filePath = path.join(repoPath, 'go.mod'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const deps: DependencyInfo[] = []; + const reqBlock = content.match(/require\s*\(([\s\S]*?)\)/); + if (reqBlock) { + const lines = reqBlock[1].split('\n'); + for (const line of lines) { + const m = line.trim().match(/^(\S+)\s+(v\S+)/); + if (m) { deps.push({ name: m[1], version: m[2], type: 'dependency' }); } + } + } + const singleReqs = content.match(/^require\s+(\S+)\s+(v\S+)/gm); + if (singleReqs) { + for (const req of singleReqs) { + const m = req.match(/^require\s+(\S+)\s+(v\S+)/); + if (m) { deps.push({ name: m[1], version: m[2], type: 'dependency' }); } + } + } + return deps; + } catch { return []; } + } + + private parseCargoToml(repoPath: string): DependencyInfo[] { + const filePath = path.join(repoPath, 'Cargo.toml'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const deps: DependencyInfo[] = []; + const sections: Array<{ pattern: RegExp; type: 'dependency' | 'devDependency' }> = [ + { pattern: /\[dependencies\]([\s\S]*?)(?=\n\[|$)/, type: 'dependency' }, + { pattern: /\[dev-dependencies\]([\s\S]*?)(?=\n\[|$)/, type: 'devDependency' }, + ]; + for (const { pattern, type } of sections) { + const block = content.match(pattern); + if (!block) { continue; } + const lines = block[1].split('\n'); + for (const line of lines) { + const simple = line.match(/^(\S+)\s*=\s*"([^"]+)"/); + if (simple) { deps.push({ name: simple[1], version: simple[2], type }); continue; } + const complex = line.match(/^(\S+)\s*=\s*\{.*version\s*=\s*"([^"]+)"/); + if (complex) { deps.push({ name: complex[1], version: complex[2], type }); } + } + } + return deps; + } catch { return []; } + } + + private parseGemfile(repoPath: string): DependencyInfo[] { + const filePath = path.join(repoPath, 'Gemfile'); + if (!fs.existsSync(filePath)) { return []; } + + try { + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + const deps: DependencyInfo[] = []; + for (const raw of lines) { + const l = raw.trim(); + if (!l.startsWith('gem ')) { continue; } + const m = l.match(/gem\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/); + if (m) { deps.push({ name: m[1], version: m[2] || '*', type: 'dependency' }); } + } + return deps; + } catch { return []; } } } diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 78e298a..7aa201e 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -137,8 +137,11 @@ export class GitHubService { if (this.tempDir && fs.existsSync(this.tempDir)) { try { fs.rmSync(this.tempDir, { recursive: true, force: true }); - } catch (error) { - } + } catch (error) { + vscode.window.showWarningMessage(`Failed to clean up temp directory ${this.tempDir}: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.tempDir = ''; + } } } @@ -586,10 +589,13 @@ export class GitHubService { calculateSimilarity(repo1: string, repo2: string): number { const commonExtensions = this.getCommonExtensions(repo1, repo2); const commonLanguages = this.getCommonLanguages(repo1, repo2); - - const extensionScore = commonExtensions.length / Math.max(this.getExtensions(repo1).length, this.getExtensions(repo2).length); - const languageScore = commonLanguages.length / Math.max(this.getLanguages(repo1).length, this.getLanguages(repo2).length); - + + const maxExt = Math.max(this.getExtensions(repo1).length, this.getExtensions(repo2).length); + const maxLang = Math.max(this.getLanguages(repo1).length, this.getLanguages(repo2).length); + + const extensionScore = maxExt > 0 ? commonExtensions.length / maxExt : 0; + const languageScore = maxLang > 0 ? commonLanguages.length / maxLang : 0; + return (extensionScore + languageScore) / 2; } @@ -606,11 +612,41 @@ export class GitHubService { } private getExtensions(repo: string): string[] { - return []; + const exts = new Set(); + const walk = (dir: string) => { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', '.git', 'vendor', 'target', 'dist', 'build'].includes(entry.name)) { continue; } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); } + else { + const ext = path.extname(entry.name).toLowerCase(); + if (ext) { exts.add(ext); } + } + } + } catch (_) {} + }; + walk(repo); + return Array.from(exts); } private getLanguages(repo: string): string[] { - return []; + const extLangMap: Record = { + '.js': 'JavaScript', '.jsx': 'JavaScript', '.ts': 'TypeScript', '.tsx': 'TypeScript', + '.py': 'Python', '.java': 'Java', '.kt': 'Kotlin', '.cs': 'C#', + '.cpp': 'C++', '.c': 'C', '.go': 'Go', '.rs': 'Rust', + '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.dart': 'Dart', + '.scala': 'Scala', '.r': 'R', '.m': 'Objective-C', '.lua': 'Lua', + '.hs': 'Haskell', '.ex': 'Elixir', '.erl': 'Erlang', '.clj': 'Clojure', + '.sh': 'Shell', '.ps1': 'PowerShell', '.html': 'HTML', '.css': 'CSS', + '.scss': 'SCSS', '.sql': 'SQL', '.vue': 'Vue', '.svelte': 'Svelte', + }; + const langs = new Set(); + for (const ext of this.getExtensions(repo)) { + const lang = extLangMap[ext]; + if (lang) { langs.add(lang); } + } + return Array.from(langs); } extractHooks(content: string, filePath: string): HookInfo[] { diff --git a/src/services/neo4jQueryService.ts b/src/services/neo4jQueryService.ts index 61e1f2c..43a3be1 100644 --- a/src/services/neo4jQueryService.ts +++ b/src/services/neo4jQueryService.ts @@ -97,6 +97,24 @@ export class Neo4jQueryService { w.focus as focus, count(c) as frequency ORDER BY frequency DESC + `, + + CROSS_SHARED_DEPS: ` + MATCH (r1:Repository)-[s:SHARES_DEPENDENCY]->(r2:Repository) + RETURN r1.fullName as repo1, r2.fullName as repo2, s.dependency as dependency, s.versions as versions + ORDER BY s.dependency + `, + + CROSS_SHARED_CONTRIBS: ` + MATCH (r1:Repository)-[o:SHARED_CONTRIBUTOR]->(r2:Repository) + RETURN r1.fullName as repo1, r2.fullName as repo2, o.name as contributor, o.email as email + ORDER BY o.name + `, + + CROSS_SHARED_LANGS: ` + MATCH (r1:Repository)-[l:SHARED_LANGUAGE]->(r2:Repository) + RETURN r1.fullName as repo1, r2.fullName as repo2, l.extension as extension, l.fileCount as fileCount + ORDER BY l.fileCount DESC ` }; } diff --git a/src/services/neo4jService.ts b/src/services/neo4jService.ts index 909511f..82e38f6 100644 --- a/src/services/neo4jService.ts +++ b/src/services/neo4jService.ts @@ -21,7 +21,20 @@ export class Neo4jService { private sessionPool: Session[] = []; private maxPoolSize = 5; + get connected(): boolean { + return this.driver !== null; + } + async connect(): Promise { + if (this.driver) { + try { + await this.driver.verifyConnectivity(); + return; + } catch (error) { + await this.disconnect(); + } + } + const neo4jConfig = await Neo4jExtensionService.checkNeo4jExtension(); if (neo4jConfig.isInstalled && neo4jConfig.isConnected && neo4jConfig.connectionDetails) { @@ -33,6 +46,7 @@ export class Neo4jService { await this.driver.verifyConnectivity(); return; } catch (error) { + vscode.window.showWarningMessage(`Neo4j extension connection failed, falling back to settings: ${error instanceof Error ? error.message : String(error)}`); } } @@ -72,9 +86,9 @@ export class Neo4jService { private async getPasswordFromNeo4jExtension(): Promise { try { const config = vscode.workspace.getConfiguration('neo4j'); - return config.get('password') || 'password'; + return config.get('password') || ''; } catch (error) { - return 'password'; + return ''; } } @@ -507,41 +521,118 @@ export class Neo4jService { throw new Error('Not connected to Neo4j'); } + const grouped: Record = {}; + for (const node of nodes) { + if (!grouped[node.type]) { + grouped[node.type] = []; + } + grouped[node.type].push(node); + } + + const queryMap: Record = { + repository: ` + UNWIND $batch AS item + MERGE (r:Repository {fullName: item.fullName}) + SET r.name = item.name, r.description = item.description, + r.language = item.language, r.stars = item.stars, + r.forks = item.forks, r.url = item.url, r.createdAt = datetime() + `, + file: ` + UNWIND $batch AS item + MATCH (r:Repository {fullName: item.repositoryId}) + MERGE (f:File {path: item.path, repositoryId: item.repositoryId}) + SET f.name = item.name, f.extension = item.extension, + f.size = item.size, f.createdAt = datetime() + MERGE (r)-[:CONTAINS]->(f) + `, + function: ` + UNWIND $batch AS item + MATCH (f:File {path: item.filePath, repositoryId: item.repositoryId}) + MERGE (func:Function {name: item.name, filePath: item.filePath, repositoryId: item.repositoryId}) + SET func.lineNumber = item.lineNumber, func.parameters = item.parameters, + func.returnType = item.returnType, func.createdAt = datetime() + MERGE (f)-[:DEFINES]->(func) + `, + class: ` + UNWIND $batch AS item + MATCH (f:File {path: item.filePath, repositoryId: item.repositoryId}) + MERGE (c:Class {name: item.name, filePath: item.filePath, repositoryId: item.repositoryId}) + SET c.lineNumber = item.lineNumber, c.methods = item.methods, + c.properties = item.properties, c.createdAt = datetime() + MERGE (f)-[:DEFINES]->(c) + `, + hook: ` + UNWIND $batch AS item + MATCH (f:File {path: item.filePath, repositoryId: item.repositoryId}) + MERGE (h:Hook {name: item.name, filePath: item.filePath, repositoryId: item.repositoryId}) + SET h.lineNumber = item.lineNumber, h.type = item.hookType, + h.dependencies = item.dependencies, h.returnType = item.returnType, h.createdAt = datetime() + MERGE (f)-[:DEFINES]->(h) + `, + decorator: ` + UNWIND $batch AS item + MATCH (f:File {path: item.filePath, repositoryId: item.repositoryId}) + MERGE (d:Decorator {name: item.name, filePath: item.filePath, repositoryId: item.repositoryId}) + SET d.lineNumber = item.lineNumber, d.target = item.target, + d.arguments = item.arguments, d.createdAt = datetime() + MERGE (f)-[:DEFINES]->(d) + `, + commit: ` + UNWIND $batch AS item + MATCH (r:Repository {fullName: item.repositoryId}) + MERGE (c:Commit {hash: item.hash, repositoryId: item.repositoryId}) + SET c.message = item.message, c.author = item.author, c.email = item.email, + c.date = datetime(item.date), c.insertions = item.insertions, + c.deletions = item.deletions, c.createdAt = datetime() + MERGE (r)-[:HAS_COMMIT]->(c) + `, + branch: ` + UNWIND $batch AS item + MATCH (r:Repository {fullName: item.repositoryId}) + MERGE (b:Branch {name: item.name, repositoryId: item.repositoryId}) + SET b.isCurrent = item.isCurrent, b.lastCommit = item.lastCommit, + b.commitCount = item.commitCount, b.createdAt = datetime() + MERGE (r)-[:HAS_BRANCH]->(b) + `, + contributor: ` + UNWIND $batch AS item + MATCH (r:Repository {fullName: item.repositoryId}) + MERGE (c:Contributor {email: item.email, repositoryId: item.repositoryId}) + SET c.name = item.name, c.commits = item.commits, + c.insertions = item.insertions, c.deletions = item.deletions, + c.firstCommit = datetime(item.firstCommit), c.lastCommit = datetime(item.lastCommit), + c.createdAt = datetime() + MERGE (r)-[:HAS_CONTRIBUTOR]->(c) + `, + dependency: ` + UNWIND $batch AS item + MATCH (r:Repository {fullName: item.repositoryId}) + MERGE (d:Dependency {name: item.name, repositoryId: item.repositoryId}) + SET d.version = item.version, d.type = item.depType, d.createdAt = datetime() + MERGE (r)-[:HAS_DEPENDENCY]->(d) + `, + complexity: ` + UNWIND $batch AS item + MATCH (f:File {path: item.filePath, repositoryId: item.repositoryId}) + MERGE (c:Complexity {filePath: item.filePath, repositoryId: item.repositoryId}) + SET c.cyclomaticComplexity = item.cyclomaticComplexity, + c.linesOfCode = item.linesOfCode, c.maintainabilityIndex = item.maintainabilityIndex, + c.createdAt = datetime() + MERGE (f)-[:HAS_COMPLEXITY]->(c) + ` + }; + const session = this.getSession(); try { const batchSize = 100; - for (let i = 0; i < nodes.length; i += batchSize) { - const batch = nodes.slice(i, i + batchSize); - const queries = batch.map(node => { - switch (node.type) { - case 'repository': - return ` - MERGE (r:Repository {fullName: $fullName}) - SET r.name = $name, - r.description = $description, - r.language = $language, - r.stars = $stars, - r.forks = $forks, - r.url = $url, - r.createdAt = datetime() - `; - case 'file': - return ` - MATCH (r:Repository {fullName: $repositoryId}) - MERGE (f:File {path: $path, repositoryId: $repositoryId}) - SET f.name = $name, - f.extension = $extension, - f.size = $size, - f.createdAt = datetime() - MERGE (r)-[:CONTAINS]->(f) - `; - default: - return ''; - } - }).filter(q => q); - - if (queries.length > 0) { - await session.run(`UNWIND $batch AS item ${queries.join(' ')}`, { batch }); + for (const [type, items] of Object.entries(grouped)) { + const query = queryMap[type]; + if (!query) { + vscode.window.showWarningMessage(`Unknown batch node type: ${type}`); + continue; + } + for (let i = 0; i < items.length; i += batchSize) { + await session.run(query, { batch: items.slice(i, i + batchSize) }); } } } finally { @@ -851,4 +942,59 @@ export class Neo4jService { this.releaseSession(session); } } + + async linkSharedDeps(): Promise { + if (!this.driver) { throw new Error('Not connected to Neo4j'); } + const session = this.getSession(); + try { + const result = await session.run(` + MATCH (r1:Repository)-[:HAS_DEPENDENCY]->(d1:Dependency) + MATCH (r2:Repository)-[:HAS_DEPENDENCY]->(d2:Dependency) + WHERE r1.fullName < r2.fullName AND d1.name = d2.name + MERGE (r1)-[s:SHARES_DEPENDENCY {dependency: d1.name}]->(r2) + SET s.versions = [d1.version, d2.version], s.updatedAt = datetime() + RETURN count(s) as cnt + `); + return result.records[0]?.get('cnt')?.toNumber() || 0; + } finally { + this.releaseSession(session); + } + } + + async linkContribOverlap(): Promise { + if (!this.driver) { throw new Error('Not connected to Neo4j'); } + const session = this.getSession(); + try { + const result = await session.run(` + MATCH (r1:Repository)-[:HAS_CONTRIBUTOR]->(c1:Contributor) + MATCH (r2:Repository)-[:HAS_CONTRIBUTOR]->(c2:Contributor) + WHERE r1.fullName < r2.fullName AND c1.email = c2.email + MERGE (r1)-[o:SHARED_CONTRIBUTOR {email: c1.email}]->(r2) + SET o.name = c1.name, o.updatedAt = datetime() + RETURN count(o) as cnt + `); + return result.records[0]?.get('cnt')?.toNumber() || 0; + } finally { + this.releaseSession(session); + } + } + + async linkLangOverlap(): Promise { + if (!this.driver) { throw new Error('Not connected to Neo4j'); } + const session = this.getSession(); + try { + const result = await session.run(` + MATCH (r1:Repository)-[:CONTAINS]->(f1:File) + MATCH (r2:Repository)-[:CONTAINS]->(f2:File) + WHERE r1.fullName < r2.fullName AND f1.extension = f2.extension + WITH r1, r2, f1.extension as ext, count(*) as fileCount + MERGE (r1)-[l:SHARED_LANGUAGE {extension: ext}]->(r2) + SET l.fileCount = fileCount, l.updatedAt = datetime() + RETURN count(l) as cnt + `); + return result.records[0]?.get('cnt')?.toNumber() || 0; + } finally { + this.releaseSession(session); + } + } } \ No newline at end of file diff --git a/src/services/repositoryAnalyzer.ts b/src/services/repositoryAnalyzer.ts index 6f1058b..3ef3231 100644 --- a/src/services/repositoryAnalyzer.ts +++ b/src/services/repositoryAnalyzer.ts @@ -77,9 +77,6 @@ export class RepositoryAnalyzer { } catch (error) { await this.githubService.cleanup(); - if (!skipConnectionManagement) { - await this.neo4jService.disconnect(); - } throw error; } finally { if (!skipConnectionManagement) { @@ -221,43 +218,51 @@ export class RepositoryAnalyzer { private async createFileNodes(files: FileInfo[], repositoryId: string): Promise { for (const file of files) { - await this.neo4jService.createFileNode({ - path: file.path, - name: file.name, - extension: file.extension, - size: file.size, - repositoryId - }); + try { + await this.neo4jService.createFileNode({ + path: file.path, + name: file.name, + extension: file.extension, + size: file.size, + repositoryId + }); + } catch (error) { + vscode.window.showWarningMessage(`Failed to create node for ${file.path}: ${error instanceof Error ? error.message : String(error)}`); + } } } private async extractCodeElements(files: FileInfo[], repositoryId: string): Promise { for (const file of files) { - if (!file.content) continue; - - const functions = this.githubService.extractFunctions(file.content, file.path); - const classes = this.githubService.extractClasses(file.content, file.path); + if (!file.content) { continue; } - for (const func of functions) { - await this.neo4jService.createFunctionNode({ - name: func.name, - filePath: file.path, - lineNumber: func.lineNumber, - parameters: func.parameters, - returnType: func.returnType, - repositoryId - }); - } + try { + const functions = this.githubService.extractFunctions(file.content, file.path); + const classes = this.githubService.extractClasses(file.content, file.path); + + for (const func of functions) { + await this.neo4jService.createFunctionNode({ + name: func.name, + filePath: file.path, + lineNumber: func.lineNumber, + parameters: func.parameters, + returnType: func.returnType, + repositoryId + }); + } - for (const cls of classes) { - await this.neo4jService.createClassNode({ - name: cls.name, - filePath: file.path, - lineNumber: cls.lineNumber, - methods: cls.methods, - properties: cls.properties, - repositoryId - }); + for (const cls of classes) { + await this.neo4jService.createClassNode({ + name: cls.name, + filePath: file.path, + lineNumber: cls.lineNumber, + methods: cls.methods, + properties: cls.properties, + repositoryId + }); + } + } catch (error) { + vscode.window.showWarningMessage(`Failed to extract code from ${file.path}: ${error instanceof Error ? error.message : String(error)}`); } } } @@ -285,30 +290,34 @@ export class RepositoryAnalyzer { } for (const file of files) { - if (!file.content) continue; - - const imports = this.githubService.extractImports(file.content); - const functionCalls = this.githubService.extractFunctionCalls(file.content); + if (!file.content) { continue; } - for (const importPath of imports) { - const resolvedPath = this.resolveImportPath(importPath, file.path, fileMap); - if (resolvedPath) { - await this.neo4jService.createImportRelationship(file.path, resolvedPath, repositoryId); + try { + const imports = this.githubService.extractImports(file.content); + const functionCalls = this.githubService.extractFunctionCalls(file.content); + + for (const importPath of imports) { + const resolvedPath = this.resolveImportPath(importPath, file.path, fileMap); + if (resolvedPath) { + await this.neo4jService.createImportRelationship(file.path, resolvedPath, repositoryId); + } } - } - for (const call of functionCalls) { - const targetFunction = this.findFunctionByName(call, functionMap); - if (targetFunction && file.content) { - const sourceFunctions = this.githubService.extractFunctions(file.content, file.path); - for (const sourceFunc of sourceFunctions) { - await this.neo4jService.createFunctionCallRelationship( - `${sourceFunc.name}_${file.path}`, - `${targetFunction.name}_${targetFunction.filePath}`, - repositoryId - ); - } + for (const call of functionCalls) { + const targetFunction = this.findFunctionByName(call, functionMap); + if (targetFunction && file.content) { + const sourceFunctions = this.githubService.extractFunctions(file.content, file.path); + for (const sourceFunc of sourceFunctions) { + await this.neo4jService.createFunctionCallRelationship( + `${sourceFunc.name}_${file.path}`, + `${targetFunction.name}_${targetFunction.filePath}`, + repositoryId + ); + } + } } + } catch (error) { + vscode.window.showWarningMessage(`Failed to create relationships for ${file.path}: ${error instanceof Error ? error.message : String(error)}`); } } } @@ -344,28 +353,20 @@ export class RepositoryAnalyzer { return null; } - async analyzeMultipleRepositories(repoUrls: string[]): Promise { - const repositories: RepositoryInfo[] = []; + async analyzeCrossRepo(progress?: vscode.Progress<{ increment?: number; message?: string }>): Promise<{ deps: number; contribs: number; langs: number }> { + await this.neo4jService.connect(); - for (const repoUrl of repoUrls) { - const repoInfo = await this.githubService.getRepositoryInfo(repoUrl); - repositories.push(repoInfo); - } + progress?.report({ increment: 10, message: 'Linking shared dependencies...' }); + const deps = await this.neo4jService.linkSharedDeps(); - for (let i = 0; i < repositories.length; i++) { - for (let j = i + 1; j < repositories.length; j++) { - const similarity = this.githubService.calculateSimilarity( - repositories[i].fullName, - repositories[j].fullName - ); - - await this.neo4jService.createSimilarityRelationship( - repositories[i].fullName, - repositories[j].fullName, - similarity - ); - } - } + progress?.report({ increment: 40, message: 'Linking contributor overlap...' }); + const contribs = await this.neo4jService.linkContribOverlap(); + + progress?.report({ increment: 70, message: 'Linking shared languages...' }); + const langs = await this.neo4jService.linkLangOverlap(); + + progress?.report({ increment: 100, message: 'Cross-repo analysis complete' }); + return { deps, contribs, langs }; } async getRepositoryStatistics(repositoryId: string): Promise { @@ -461,7 +462,7 @@ export class RepositoryAnalyzer { } private async analyzeDependencies(repoPath: string, repositoryId: string): Promise { - const dependencies = await this.dependencyAnalyzer.analyzePackageJson(repoPath); + const dependencies = await this.dependencyAnalyzer.analyzeDeps(repoPath); for (const dependency of dependencies) { await this.neo4jService.createDependencyNode({ diff --git a/src/views/batchManager.ts b/src/views/batchManager.ts index 1fa757c..9494e8c 100644 --- a/src/views/batchManager.ts +++ b/src/views/batchManager.ts @@ -201,15 +201,26 @@ export class BatchManagerView { @@ -419,16 +448,25 @@ export class BatchManagerView { return; } - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: `Processing batch job: ${job.name}`, - cancellable: false - }, async (progress) => { - await this.batchProcessor.processBatch(jobId, progress); - }); - this.updateJobList(); - vscode.window.showInformationMessage(`Batch job "${job.name}" finished`); + + const pollId = setInterval(() => this.updateJobList(), 2000); + + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Processing batch job: ${job.name}`, + cancellable: false + }, async (progress) => { + await this.batchProcessor.processBatch(jobId, progress); + }); + vscode.window.showInformationMessage(`Batch job "${job.name}" finished`); + } catch (error) { + vscode.window.showErrorMessage(`Batch job failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + clearInterval(pollId); + this.updateJobList(); + } } private async exportJob(jobId: string): Promise { diff --git a/src/views/graphView.ts b/src/views/graphView.ts new file mode 100644 index 0000000..8c2817b --- /dev/null +++ b/src/views/graphView.ts @@ -0,0 +1,314 @@ +import * as vscode from 'vscode'; +import { Neo4jService } from '../services/neo4jService'; + +export class GraphView { + private panel: vscode.WebviewPanel | undefined; + + constructor(private neo4j: Neo4jService) {} + + async show(): Promise { + if (this.panel) { + this.panel.reveal(); + return; + } + + this.panel = vscode.window.createWebviewPanel( + 'git4neo.graphView', + 'Git4Neo Graph', + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true } + ); + + this.panel.webview.html = this.getHtml(); + this.panel.webview.onDidReceiveMessage(this.onMsg.bind(this)); + this.panel.onDidDispose(() => { this.panel = undefined; }); + } + + private async onMsg(msg: any): Promise { + if (msg.command === 'fetchGraph') { + try { + await this.neo4j.connect(); + const nodes = await this.neo4j.executeQuery(` + MATCH (n) + WHERE n:Repository OR n:File OR n:Contributor OR n:Dependency + RETURN id(n) as id, labels(n)[0] as label, + coalesce(n.name, n.fullName, n.path, n.email) as name + LIMIT 200 + `); + const edges = await this.neo4j.executeQuery(` + MATCH (a)-[r]->(b) + WHERE (a:Repository OR a:File OR a:Contributor OR a:Dependency) + AND (b:Repository OR b:File OR b:Contributor OR b:Dependency) + RETURN id(a) as src, id(b) as tgt, type(r) as rel + LIMIT 500 + `); + this.panel?.webview.postMessage({ + command: 'graphData', + nodes: nodes.map((n: any) => ({ + id: typeof n.id?.toNumber === 'function' ? n.id.toNumber() : n.id, + label: n.label, + name: n.name + })), + edges: edges.map((e: any) => ({ + src: typeof e.src?.toNumber === 'function' ? e.src.toNumber() : e.src, + tgt: typeof e.tgt?.toNumber === 'function' ? e.tgt.toNumber() : e.tgt, + rel: e.rel + })) + }); + } catch (error) { + this.panel?.webview.postMessage({ + command: 'error', + message: error instanceof Error ? error.message : String(error) + }); + } + } + if (msg.command === 'fetchRepo') { + try { + await this.neo4j.connect(); + const nodes = await this.neo4j.executeQuery(` + MATCH (r:Repository {fullName: $repo})-[rel]-(n) + RETURN id(n) as id, labels(n)[0] as label, + coalesce(n.name, n.fullName, n.path, n.email) as name + LIMIT 150 + `, { repo: msg.repo }); + const repoNode = await this.neo4j.executeQuery(` + MATCH (r:Repository {fullName: $repo}) + RETURN id(r) as id, 'Repository' as label, r.fullName as name + `, { repo: msg.repo }); + const allNodes = [...repoNode, ...nodes]; + const ids = new Set(allNodes.map((n: any) => typeof n.id?.toNumber === 'function' ? n.id.toNumber() : n.id)); + const edges = await this.neo4j.executeQuery(` + MATCH (a)-[r]->(b) + WHERE id(a) IN $ids AND id(b) IN $ids + RETURN id(a) as src, id(b) as tgt, type(r) as rel + `, { ids: Array.from(ids) }); + this.panel?.webview.postMessage({ + command: 'graphData', + nodes: allNodes.map((n: any) => ({ + id: typeof n.id?.toNumber === 'function' ? n.id.toNumber() : n.id, + label: n.label, + name: n.name + })), + edges: edges.map((e: any) => ({ + src: typeof e.src?.toNumber === 'function' ? e.src.toNumber() : e.src, + tgt: typeof e.tgt?.toNumber === 'function' ? e.tgt.toNumber() : e.tgt, + rel: e.rel + })) + }); + } catch (error) { + this.panel?.webview.postMessage({ + command: 'error', + message: error instanceof Error ? error.message : String(error) + }); + } + } + } + + private getHtml(): string { + return ` + + + + + + +
+ + + +
+
+
+
Loading graph...
+ + + +`; + } +} diff --git a/src/views/insightsPanel.ts b/src/views/insightsPanel.ts new file mode 100644 index 0000000..dc17f3b --- /dev/null +++ b/src/views/insightsPanel.ts @@ -0,0 +1,130 @@ +import * as vscode from 'vscode'; +import { Neo4jService } from '../services/neo4jService'; +import { Neo4jQueryService } from '../services/neo4jQueryService'; + +class InsightItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly children?: InsightItem[], + public readonly detail?: string + ) { + super(label, collapsibleState); + if (detail) { + this.description = detail; + this.tooltip = `${label}: ${detail}`; + } + } +} + +export class InsightsPanel implements vscode.TreeDataProvider { + private _onDidChange = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChange.event; + + private repoId: string = ''; + private cache = new Map(); + + constructor(private neo4j: Neo4jService) {} + + setRepo(repoId: string) { + this.repoId = repoId; + this.cache.clear(); + this._onDidChange.fire(undefined); + } + + refresh() { + this.cache.clear(); + this._onDidChange.fire(undefined); + } + + getTreeItem(el: InsightItem): vscode.TreeItem { + return el; + } + + async getChildren(el?: InsightItem): Promise { + if (!this.repoId) { + return [new InsightItem('No repository selected', vscode.TreeItemCollapsibleState.None, undefined, 'Run "Connect Repository" first')]; + } + + if (!this.neo4j.connected) { + return [new InsightItem('Not connected to Neo4j', vscode.TreeItemCollapsibleState.None, undefined, 'Run "Setup Wizard"')]; + } + + if (el?.children) { + return el.children; + } + + if (!el) { + return [ + new InsightItem('Hotspots', vscode.TreeItemCollapsibleState.Collapsed), + new InsightItem('Top Contributors', vscode.TreeItemCollapsibleState.Collapsed), + new InsightItem('Dependencies', vscode.TreeItemCollapsibleState.Collapsed), + new InsightItem('Code Quality', vscode.TreeItemCollapsibleState.Collapsed), + new InsightItem('Recent Commits', vscode.TreeItemCollapsibleState.Collapsed), + ]; + } + + const cached = this.cache.get(el.label); + if (cached) { return cached; } + + try { + const items = await this.fetchItems(el.label); + this.cache.set(el.label, items); + return items; + } catch (error) { + return [new InsightItem('Failed to load', vscode.TreeItemCollapsibleState.None, undefined, error instanceof Error ? error.message : String(error))]; + } + } + + private async fetchItems(cat: string): Promise { + const p = { repositoryId: this.repoId }; + + switch (cat) { + case 'Hotspots': { + const rows = await this.neo4j.executeQuery(Neo4jQueryService.QUERIES.HOTSPOTS, p); + if (rows.length === 0) { return [new InsightItem('No data', vscode.TreeItemCollapsibleState.None)]; } + return rows.map((r: any) => new InsightItem( + r.file, vscode.TreeItemCollapsibleState.None, undefined, + `complexity: ${r.complexity} | loc: ${r.loc}` + )); + } + case 'Top Contributors': { + const rows = await this.neo4j.executeQuery(Neo4jQueryService.QUERIES.TOP_CONTRIBUTORS, p); + if (rows.length === 0) { return [new InsightItem('No data', vscode.TreeItemCollapsibleState.None)]; } + return rows.map((r: any) => new InsightItem( + r.name, vscode.TreeItemCollapsibleState.None, undefined, + `${r.commits} commits | +${r.insertions} -${r.deletions}` + )); + } + case 'Dependencies': { + const rows = await this.neo4j.executeQuery(Neo4jQueryService.QUERIES.DEPENDENCY_GRAPH, p); + if (rows.length === 0) { return [new InsightItem('No data', vscode.TreeItemCollapsibleState.None)]; } + return rows.map((r: any) => new InsightItem( + r.name, vscode.TreeItemCollapsibleState.None, undefined, + `${r.version} (${r.type})` + )); + } + case 'Code Quality': { + const rows = await this.neo4j.executeQuery(Neo4jQueryService.QUERIES.CODE_QUALITY_SUMMARY, p); + if (rows.length === 0) { return [new InsightItem('No data', vscode.TreeItemCollapsibleState.None)]; } + const r = rows[0] as any; + return [ + new InsightItem('Files analyzed', vscode.TreeItemCollapsibleState.None, undefined, String(r.totalFiles)), + new InsightItem('Avg complexity', vscode.TreeItemCollapsibleState.None, undefined, Number(r.avgComplexity).toFixed(1)), + new InsightItem('Avg lines of code', vscode.TreeItemCollapsibleState.None, undefined, Number(r.avgLoc).toFixed(0)), + new InsightItem('Avg maintainability', vscode.TreeItemCollapsibleState.None, undefined, Number(r.avgMaintainability).toFixed(1)), + ]; + } + case 'Recent Commits': { + const rows = await this.neo4j.executeQuery(Neo4jQueryService.QUERIES.COMMIT_TRENDS, p); + if (rows.length === 0) { return [new InsightItem('No data', vscode.TreeItemCollapsibleState.None)]; } + return rows.slice(0, 15).map((r: any) => new InsightItem( + r.message?.substring(0, 60) || '(no message)', vscode.TreeItemCollapsibleState.None, undefined, + r.author + )); + } + default: + return []; + } + } +}