194 строки
6.9 KiB
Python
194 строки
6.9 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from backend.config_store import get_github_token, get_github_username
|
|
|
|
DATA_DIR = Path(os.getenv('DEVCONSOLE_DATA_DIR', '/var/lib/devconsole'))
|
|
PROJECTS_DIR = DATA_DIR / 'projects'
|
|
|
|
|
|
@dataclass
|
|
class DependencyStep:
|
|
title: str
|
|
command: str
|
|
cwd: str
|
|
safe: bool = True
|
|
|
|
|
|
@dataclass
|
|
class ProjectAnalysis:
|
|
repo_url: str
|
|
workspace: str
|
|
detected_stack: list[str]
|
|
files_found: list[str]
|
|
dependency_steps: list[DependencyStep]
|
|
notes: list[str]
|
|
|
|
def to_dict(self) -> dict:
|
|
data = asdict(self)
|
|
data['dependency_steps'] = [asdict(step) for step in self.dependency_steps]
|
|
return data
|
|
|
|
|
|
def _slug_from_repo_url(repo_url: str) -> str:
|
|
parsed = urlparse(repo_url)
|
|
path = parsed.path.strip('/').replace('.git', '')
|
|
slug = re.sub(r'[^a-zA-Z0-9_.-]+', '-', path)
|
|
return slug or 'project'
|
|
|
|
|
|
|
|
def _build_authenticated_repo_url(repo_url: str) -> str:
|
|
username = get_github_username().strip()
|
|
token = (get_github_token() or '').strip()
|
|
|
|
if not username or not token:
|
|
return repo_url
|
|
|
|
if 'github.com' not in repo_url:
|
|
return repo_url
|
|
|
|
return repo_url.replace(
|
|
'https://',
|
|
f'https://{username}:{token}@',
|
|
1,
|
|
)
|
|
|
|
|
|
def clone_or_update_project(repo_url: str) -> Path:
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
slug = _slug_from_repo_url(repo_url)
|
|
workspace = PROJECTS_DIR / slug
|
|
|
|
auth_repo_url = _build_authenticated_repo_url(repo_url)
|
|
|
|
if (workspace / '.git').exists():
|
|
subprocess.run(['git', '-C', str(workspace), 'fetch', '--all'], check=True)
|
|
subprocess.run(['git', '-C', str(workspace), 'pull', '--ff-only'], check=False)
|
|
else:
|
|
if workspace.exists():
|
|
shutil.rmtree(workspace)
|
|
subprocess.run(['git', 'clone', auth_repo_url, str(workspace)], check=True)
|
|
|
|
return workspace
|
|
|
|
|
|
def _exists(root: Path, relative: str) -> bool:
|
|
return (root / relative).exists()
|
|
|
|
|
|
def _find_files(root: Path, names: set[str], limit: int = 80) -> list[Path]:
|
|
result: list[Path] = []
|
|
ignored_dirs = {'.git', 'node_modules', 'vendor', '.venv', 'venv', 'build', 'dist', '.dart_tool', '.gradle'}
|
|
|
|
for current, dirs, files in os.walk(root):
|
|
dirs[:] = [d for d in dirs if d not in ignored_dirs]
|
|
for file in files:
|
|
if file in names:
|
|
result.append(Path(current) / file)
|
|
if len(result) >= limit:
|
|
return result
|
|
return result
|
|
|
|
|
|
def analyze_workspace(repo_url: str, workspace: Path) -> ProjectAnalysis:
|
|
detected_stack: list[str] = []
|
|
files_found: list[str] = []
|
|
steps: list[DependencyStep] = []
|
|
notes: list[str] = []
|
|
|
|
marker_files = _find_files(
|
|
workspace,
|
|
{
|
|
'package.json', 'requirements.txt', 'pyproject.toml', 'Pipfile',
|
|
'composer.json', 'pubspec.yaml', 'build.gradle', 'build.gradle.kts',
|
|
'settings.gradle', 'settings.gradle.kts', 'Dockerfile', 'docker-compose.yml',
|
|
'docker-compose.yaml', 'go.mod', 'Cargo.toml'
|
|
}
|
|
)
|
|
|
|
for marker in marker_files:
|
|
relative = marker.relative_to(workspace).as_posix()
|
|
files_found.append(relative)
|
|
cwd = marker.parent.as_posix()
|
|
|
|
if marker.name == 'package.json':
|
|
detected_stack.append('Node.js')
|
|
package_lock = marker.parent / 'package-lock.json'
|
|
yarn_lock = marker.parent / 'yarn.lock'
|
|
pnpm_lock = marker.parent / 'pnpm-lock.yaml'
|
|
if pnpm_lock.exists():
|
|
steps.append(DependencyStep('Install Node dependencies with pnpm', 'corepack enable && pnpm install', cwd))
|
|
elif yarn_lock.exists():
|
|
steps.append(DependencyStep('Install Node dependencies with yarn', 'corepack enable && yarn install', cwd))
|
|
elif package_lock.exists():
|
|
steps.append(DependencyStep('Install Node dependencies with npm ci', 'npm ci', cwd))
|
|
else:
|
|
steps.append(DependencyStep('Install Node dependencies with npm', 'npm install', cwd))
|
|
|
|
elif marker.name == 'requirements.txt':
|
|
detected_stack.append('Python')
|
|
steps.append(DependencyStep('Create Python virtualenv', 'python3 -m venv .venv', cwd))
|
|
steps.append(DependencyStep('Install Python requirements', '.venv/bin/pip install --upgrade pip && .venv/bin/pip install -r requirements.txt', cwd))
|
|
|
|
elif marker.name == 'pyproject.toml':
|
|
detected_stack.append('Python')
|
|
steps.append(DependencyStep('Install Python project', 'python3 -m venv .venv && .venv/bin/pip install --upgrade pip && .venv/bin/pip install -e .', cwd))
|
|
|
|
elif marker.name == 'composer.json':
|
|
detected_stack.append('PHP/Composer')
|
|
steps.append(DependencyStep('Install PHP Composer dependencies', 'composer install', cwd))
|
|
|
|
elif marker.name == 'pubspec.yaml':
|
|
detected_stack.append('Flutter/Dart')
|
|
steps.append(DependencyStep('Install Flutter/Dart dependencies', 'flutter pub get', cwd))
|
|
|
|
elif marker.name in {'build.gradle', 'build.gradle.kts'}:
|
|
detected_stack.append('Gradle/Android')
|
|
gradlew = marker.parent / 'gradlew'
|
|
command = './gradlew tasks' if gradlew.exists() else 'gradle tasks'
|
|
steps.append(DependencyStep('Prepare Gradle project', command, cwd))
|
|
|
|
elif marker.name in {'docker-compose.yml', 'docker-compose.yaml'}:
|
|
detected_stack.append('Docker Compose')
|
|
steps.append(DependencyStep('Pull Docker Compose images', 'docker compose pull', cwd))
|
|
|
|
elif marker.name == 'Dockerfile':
|
|
detected_stack.append('Docker')
|
|
image_name = _slug_from_repo_url(repo_url).replace('/', '-').lower()
|
|
steps.append(DependencyStep('Build Docker image', f'docker build -t {image_name}:dev .', cwd))
|
|
|
|
elif marker.name == 'go.mod':
|
|
detected_stack.append('Go')
|
|
steps.append(DependencyStep('Download Go modules', 'go mod download', cwd))
|
|
|
|
elif marker.name == 'Cargo.toml':
|
|
detected_stack.append('Rust')
|
|
steps.append(DependencyStep('Fetch Cargo dependencies', 'cargo fetch', cwd))
|
|
|
|
if not files_found:
|
|
notes.append('No known dependency manifest found. AI/manual analysis required.')
|
|
|
|
unique_stack = list(dict.fromkeys(detected_stack))
|
|
unique_files = list(dict.fromkeys(files_found))
|
|
|
|
return ProjectAnalysis(
|
|
repo_url=repo_url,
|
|
workspace=workspace.as_posix(),
|
|
detected_stack=unique_stack,
|
|
files_found=unique_files,
|
|
dependency_steps=steps,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
def analyze_github_project(repo_url: str) -> ProjectAnalysis:
|
|
workspace = clone_or_update_project(repo_url)
|
|
return analyze_workspace(repo_url, workspace)
|