From 7285c25aac3d8a0f1517773d37e6b5425791d800 Mon Sep 17 00:00:00 2001 From: Chandini Date: Thu, 2 Oct 2025 12:13:20 +0530 Subject: [PATCH] backend changes --- DEPLOYMENT_FIX_GUIDE.md | 171 ++++++ databases/scripts/fix-schema-conflicts.sql | 64 ++ docker-compose.yml | 14 +- scripts/fix-deployment-issues.sh | 156 +++++ self-improving-generator/Dockerfile | 2 +- self-improving-generator/src/main.py | 107 +++- services/git-integration.zip | Bin 2775615 -> 2775615 bytes services/git-integration/package-lock.json | 111 ++++ services/git-integration/package.json | 1 + services/git-integration/src/app.js | 63 +- .../src/migrations/001_github_integration.sql | 8 +- .../002_repository_file_storage.sql | 6 +- .../003_add_user_id_to_template_refs.sql | 6 +- .../src/migrations/005_webhook_commits.sql | 4 +- .../src/migrations/006_commit_changes.sql | 2 +- .../migrations/007_add_last_synced_commit.sql | 2 +- .../009_provider_webhook_tables.sql | 6 +- .../src/migrations/010_remove_template_id.sql | 2 +- ...012_add_user_id_to_github_repositories.sql | 4 +- .../013_repository_commit_details.sql | 30 + .../014_additional_oauth_providers.sql | 48 ++ .../migrations/015_diff_storage_system.sql | 149 +++++ .../016_missing_columns_and_indexes.sql | 25 + ...mplete_schema_from_provided_migrations.sql | 569 ++++++++++++++++++ .../src/models/commit-details.model.js | 79 +++ .../src/models/commit-files.model.js | 102 ++++ .../src/models/diff-storage.model.js | 226 +++++++ .../src/models/oauth-tokens.model.js | 227 +++++++ .../src/routes/commits.routes.js | 273 +++++++++ .../src/routes/enhanced-webhooks.routes.js | 369 ++++++++++++ .../src/routes/github-integration.routes.js | 6 +- .../src/routes/oauth-providers.routes.js | 384 ++++++++++++ .../git-integration/src/routes/vcs.routes.js | 83 ++- .../src/routes/webhook.routes.js | 16 +- .../src/services/bitbucket-oauth.js | 5 +- .../src/services/commit-tracking.service.js | 239 ++++++++ .../src/services/diff-processing.service.js | 421 +++++++++++++ .../enhanced-diff-processing.service.js | 336 +++++++++++ .../src/services/enhanced-webhook.service.js | 509 ++++++++++++++++ .../src/services/file-storage.service.js | 54 +- .../src/services/git-repo.service.js | 20 + .../src/services/gitea-oauth.js | 139 ++++- .../services/providers/bitbucket.adapter.js | 60 +- .../src/services/providers/gitea.adapter.js | 205 ++++++- .../src/services/providers/gitlab.adapter.js | 121 +++- .../src/services/vcs-webhook.service.js | 266 ++++---- .../src/services/webhook.service.js | 149 ++--- services/git-integration/test-repo.js | 94 --- services/git-integration/test-webhook.js | 70 --- 49 files changed, 5446 insertions(+), 557 deletions(-) create mode 100644 DEPLOYMENT_FIX_GUIDE.md create mode 100644 databases/scripts/fix-schema-conflicts.sql create mode 100755 scripts/fix-deployment-issues.sh create mode 100644 services/git-integration/src/migrations/013_repository_commit_details.sql create mode 100644 services/git-integration/src/migrations/014_additional_oauth_providers.sql create mode 100644 services/git-integration/src/migrations/015_diff_storage_system.sql create mode 100644 services/git-integration/src/migrations/016_missing_columns_and_indexes.sql create mode 100644 services/git-integration/src/migrations/017_complete_schema_from_provided_migrations.sql create mode 100644 services/git-integration/src/models/commit-details.model.js create mode 100644 services/git-integration/src/models/commit-files.model.js create mode 100644 services/git-integration/src/models/diff-storage.model.js create mode 100644 services/git-integration/src/models/oauth-tokens.model.js create mode 100644 services/git-integration/src/routes/commits.routes.js create mode 100644 services/git-integration/src/routes/enhanced-webhooks.routes.js create mode 100644 services/git-integration/src/routes/oauth-providers.routes.js create mode 100644 services/git-integration/src/services/commit-tracking.service.js create mode 100644 services/git-integration/src/services/diff-processing.service.js create mode 100644 services/git-integration/src/services/enhanced-diff-processing.service.js create mode 100644 services/git-integration/src/services/enhanced-webhook.service.js delete mode 100644 services/git-integration/test-repo.js delete mode 100644 services/git-integration/test-webhook.js diff --git a/DEPLOYMENT_FIX_GUIDE.md b/DEPLOYMENT_FIX_GUIDE.md new file mode 100644 index 0000000..a35ad0b --- /dev/null +++ b/DEPLOYMENT_FIX_GUIDE.md @@ -0,0 +1,171 @@ +# ๐Ÿš€ Microservices Deployment Fix Guide + +## ๐Ÿ” Issues Identified + +### 1. N8N Service Failure (Exit Code 1) +- **Root Cause**: Database schema conflicts and timing issues +- **Symptoms**: `pipeline_n8n` container exits with code 1 + +### 2. PostgreSQL Constraint Violations +- **Root Cause**: Duplicate type/table creation attempts +- **Error**: `duplicate key value violates unique constraint "pg_type_typname_nsp_index"` + +## ๐Ÿ› ๏ธ Solutions Implemented + +### 1. Enhanced N8N Configuration +- Added dedicated `n8n` schema +- Improved health checks with longer start period +- Added restart policy and better logging +- Ensured proper dependency ordering + +### 2. Database Schema Cleanup +- Created schema conflict resolution script +- Proper table ownership and permissions +- Separated n8n tables into dedicated schema + +### 3. Deployment Orchestration +- Staged service startup to prevent race conditions +- Proper dependency management +- Volume cleanup for fresh starts + +## ๐Ÿš€ Deployment Steps + +### Option 1: Automated Fix (Recommended) +```bash +cd /home/tech4biz/Desktop/Projectsnew/CODENUK1/codenuk-backend-live +./scripts/fix-deployment-issues.sh +``` + +### Option 2: Manual Step-by-Step + +#### Step 1: Clean Environment +```bash +# Stop all services +docker-compose down --volumes --remove-orphans + +# Clean Docker system +docker system prune -f +docker volume prune -f + +# Remove problematic volumes +docker volume rm codenuk-backend-live_postgres_data 2>/dev/null || true +docker volume rm codenuk-backend-live_n8n_data 2>/dev/null || true +``` + +#### Step 2: Start Core Infrastructure +```bash +# Start databases first +docker-compose up -d postgres redis mongodb rabbitmq + +# Wait for readiness +sleep 30 +``` + +#### Step 3: Fix Database Schema +```bash +# Apply schema fixes +docker exec -i pipeline_postgres psql -U pipeline_admin -d dev_pipeline < databases/scripts/fix-schema-conflicts.sql +``` + +#### Step 4: Run Migrations +```bash +docker-compose up migrations +``` + +#### Step 5: Start Services in Stages +```bash +# Stage 1: Core services +docker-compose up -d n8n api-gateway requirement-processor + +# Stage 2: Generation services +docker-compose up -d tech-stack-selector architecture-designer code-generator + +# Stage 3: User services +docker-compose up -d user-auth template-manager + +# Stage 4: Additional services +docker-compose up -d ai-mockup-service git-integration web-dashboard +``` + +## ๐Ÿฅ Health Verification + +### Check Service Status +```bash +docker-compose ps +``` + +### Check N8N Specifically +```bash +# Check n8n logs +docker-compose logs n8n + +# Test n8n endpoint +curl -f http://localhost:5678/healthz +``` + +### Check Database +```bash +# Connect to database +docker exec -it pipeline_postgres psql -U pipeline_admin -d dev_pipeline + +# List schemas +\dn + +# Check n8n tables +\dt n8n.* +``` + +## ๐Ÿ”ง Troubleshooting + +### If N8N Still Fails +1. Check logs: `docker-compose logs n8n` +2. Verify database connection: `docker exec pipeline_postgres pg_isready` +3. Check n8n schema exists: `docker exec -it pipeline_postgres psql -U pipeline_admin -d dev_pipeline -c "\dn"` + +### If Database Conflicts Persist +1. Run schema cleanup again: `docker exec -i pipeline_postgres psql -U pipeline_admin -d dev_pipeline < databases/scripts/fix-schema-conflicts.sql` +2. Check for remaining conflicts: `docker-compose logs postgres | grep ERROR` + +### If Services Won't Start +1. Check dependencies: `docker-compose config --services` +2. Start services individually: `docker-compose up [service-name]` +3. Check resource usage: `docker stats` + +## ๐Ÿ“Š Expected Results + +After successful deployment: +- โœ… All services should show "Up" status +- โœ… N8N accessible at http://localhost:5678 +- โœ… No database constraint errors +- โœ… All health checks passing + +## ๐ŸŽฏ Key Improvements Made + +1. **N8N Configuration**: + - Dedicated schema isolation + - Better dependency management + - Improved health checks + - Restart policies + +2. **Database Management**: + - Schema conflict resolution + - Proper table ownership + - Clean migration process + +3. **Deployment Process**: + - Staged service startup + - Volume cleanup + - Dependency ordering + +## ๐Ÿ“ž Support + +If issues persist: +1. Check service logs: `docker-compose logs [service-name]` +2. Verify network connectivity: `docker network ls` +3. Check resource usage: `docker system df` +4. Review configuration: `docker-compose config` + +--- +**Last Updated**: September 30, 2025 +**Build Number**: 24+ (Fixed) +**Status**: โœ… Ready for Deployment diff --git a/databases/scripts/fix-schema-conflicts.sql b/databases/scripts/fix-schema-conflicts.sql new file mode 100644 index 0000000..faf3ef9 --- /dev/null +++ b/databases/scripts/fix-schema-conflicts.sql @@ -0,0 +1,64 @@ +-- Fix Schema Conflicts and Prepare for Clean Deployment +-- This script resolves duplicate key constraint violations + +-- Create n8n schema if it doesn't exist +CREATE SCHEMA IF NOT EXISTS n8n; + +-- Clean up any existing conflicting types/tables +DROP TYPE IF EXISTS claude_recommendations CASCADE; +DROP TABLE IF EXISTS claude_recommendations CASCADE; + +-- Clean up n8n related tables if they exist in public schema +DROP TABLE IF EXISTS public.n8n_credentials_entity CASCADE; +DROP TABLE IF EXISTS public.n8n_execution_entity CASCADE; +DROP TABLE IF EXISTS public.n8n_workflow_entity CASCADE; +DROP TABLE IF EXISTS public.n8n_webhook_entity CASCADE; +DROP TABLE IF EXISTS public.n8n_tag_entity CASCADE; +DROP TABLE IF EXISTS public.n8n_workflows_tags CASCADE; + +-- Reset any conflicting sequences +DROP SEQUENCE IF EXISTS claude_recommendations_id_seq CASCADE; + +-- Ensure proper permissions for n8n schema +GRANT ALL PRIVILEGES ON SCHEMA n8n TO pipeline_admin; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA n8n TO pipeline_admin; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA n8n TO pipeline_admin; + +-- Create claude_recommendations table properly (if needed by services) +CREATE TABLE IF NOT EXISTS claude_recommendations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id VARCHAR(255) UNIQUE NOT NULL, + domain VARCHAR(100) NOT NULL, + budget DECIMAL(10,2) NOT NULL, + preferred_technologies TEXT[], + template_id UUID, + stack_name VARCHAR(255) NOT NULL, + monthly_cost DECIMAL(10,2) NOT NULL, + setup_cost DECIMAL(10,2) NOT NULL, + team_size VARCHAR(50) NOT NULL, + development_time INTEGER NOT NULL, + satisfaction INTEGER NOT NULL CHECK (satisfaction >= 0 AND satisfaction <= 100), + success_rate INTEGER NOT NULL CHECK (success_rate >= 0 AND success_rate <= 100), + frontend VARCHAR(100) NOT NULL, + backend VARCHAR(100) NOT NULL, + database VARCHAR(100) NOT NULL, + cloud VARCHAR(100) NOT NULL, + testing VARCHAR(100) NOT NULL, + mobile VARCHAR(100), + devops VARCHAR(100) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_claude_recommendations_request_id ON claude_recommendations(request_id); +CREATE INDEX IF NOT EXISTS idx_claude_recommendations_domain ON claude_recommendations(domain); +CREATE INDEX IF NOT EXISTS idx_claude_recommendations_created_at ON claude_recommendations(created_at); + +-- Ensure proper ownership +ALTER TABLE claude_recommendations OWNER TO pipeline_admin; + +-- Log the cleanup +INSERT INTO schema_migrations (service, version, description, applied_at) +VALUES ('database-cleanup', 'fix_schema_conflicts', 'Fixed schema conflicts and prepared for clean deployment', CURRENT_TIMESTAMP) +ON CONFLICT (service, version) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml index 64946d3..6565cb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -703,7 +703,7 @@ services: - HOST=0.0.0.0 - DATABASE_URL=postgresql://pipeline_admin:secure_pipeline_2024@postgres:5432/dev_pipeline - CLAUDE_API_KEY=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 - - REDIS_URL=redis://:redis_secure_2024@pipeline_redis:6379 + - REDIS_URL=redis://:redis_secure_2024@redis:6379 - SERVICE_PORT=8007 - LOG_LEVEL=INFO - DEFAULT_TARGET_QUALITY=0.85 @@ -726,9 +726,9 @@ services: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8007/health"] interval: 30s - timeout: 10s - retries: 3 - start_period: 40s + timeout: 15s + retries: 5 + start_period: 120s # ===================================== # Workflow Orchestration # ===================================== @@ -795,6 +795,9 @@ services: - DB_POSTGRESDB_DATABASE=dev_pipeline - DB_POSTGRESDB_USER=pipeline_admin - DB_POSTGRESDB_PASSWORD=secure_pipeline_2024 + - DB_POSTGRESDB_SCHEMA=n8n + - N8N_LOG_LEVEL=info + - N8N_METRICS=true volumes: - n8n_data:/home/node/.n8n - ./orchestration/n8n/workflows:/home/node/.n8n/workflows @@ -807,12 +810,15 @@ services: condition: service_healthy rabbitmq: condition: service_healthy + migrations: + condition: service_completed_successfully healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:5678/healthz"] interval: 30s timeout: 10s retries: 5 start_period: 60s + restart: unless-stopped # ===================================== # Volumes diff --git a/scripts/fix-deployment-issues.sh b/scripts/fix-deployment-issues.sh new file mode 100755 index 0000000..eea5749 --- /dev/null +++ b/scripts/fix-deployment-issues.sh @@ -0,0 +1,156 @@ +#!/bin/bash + +echo "๐Ÿ”ง Fixing Microservices Deployment Issues" +echo "========================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 1. Stop all services +print_status "Stopping all services..." +docker-compose down --volumes --remove-orphans + +# 2. Clean up Docker system +print_status "Cleaning up Docker system..." +docker system prune -f +docker volume prune -f + +# 3. Remove problematic volumes +print_status "Removing problematic volumes..." +docker volume rm codenuk-backend-live_postgres_data 2>/dev/null || true +docker volume rm codenuk-backend-live_n8n_data 2>/dev/null || true +docker volume rm codenuk-backend-live_migration_state 2>/dev/null || true + +# 4. Clean database schema conflicts +print_status "Preparing clean database environment..." +cat > /tmp/clean_db.sql << 'EOF' +-- Clean up any existing schema conflicts +DROP TYPE IF EXISTS claude_recommendations CASCADE; +DROP TABLE IF EXISTS claude_recommendations CASCADE; + +-- Clean up n8n related tables if they exist +DROP TABLE IF EXISTS n8n_credentials_entity CASCADE; +DROP TABLE IF EXISTS n8n_execution_entity CASCADE; +DROP TABLE IF EXISTS n8n_workflow_entity CASCADE; +DROP TABLE IF EXISTS n8n_webhook_entity CASCADE; +DROP TABLE IF EXISTS n8n_tag_entity CASCADE; +DROP TABLE IF EXISTS n8n_workflows_tags CASCADE; + +-- Reset any conflicting sequences +DROP SEQUENCE IF EXISTS claude_recommendations_id_seq CASCADE; +EOF + +# 5. Start only core infrastructure first +print_status "Starting core infrastructure services..." +docker-compose up -d postgres redis mongodb rabbitmq + +# 6. Wait for databases to be ready +print_status "Waiting for databases to be ready..." +sleep 30 + +# Check if postgres is ready +print_status "Checking PostgreSQL readiness..." +for i in {1..30}; do + if docker exec pipeline_postgres pg_isready -U pipeline_admin -d dev_pipeline; then + print_status "PostgreSQL is ready!" + break + fi + print_warning "Waiting for PostgreSQL... ($i/30)" + sleep 2 +done + +# 7. Clean the database +print_status "Cleaning database schema conflicts..." +docker exec -i pipeline_postgres psql -U pipeline_admin -d dev_pipeline < /tmp/clean_db.sql 2>/dev/null || true + +# 8. Run migrations +print_status "Running database migrations..." +docker-compose up migrations + +# Wait for migrations to complete +print_status "Waiting for migrations to complete..." +sleep 10 + +# 9. Start n8n with proper initialization +print_status "Starting n8n service..." +docker-compose up -d n8n + +# Wait for n8n to initialize +print_status "Waiting for n8n to initialize..." +sleep 20 + +# 10. Start remaining services in batches +print_status "Starting core services..." +docker-compose up -d \ + api-gateway \ + requirement-processor \ + tech-stack-selector \ + architecture-designer + +sleep 15 + +print_status "Starting generation services..." +docker-compose up -d \ + code-generator \ + test-generator \ + deployment-manager + +sleep 15 + +print_status "Starting user services..." +docker-compose up -d \ + user-auth \ + template-manager \ + unison + +sleep 15 + +print_status "Starting additional services..." +docker-compose up -d \ + ai-mockup-service \ + git-integration \ + self-improving-generator \ + web-dashboard + +# 11. Final health check +print_status "Performing final health check..." +sleep 30 + +echo "" +echo "๐Ÿฅ Service Health Check" +echo "======================" + +# Check service status +docker-compose ps + +echo "" +print_status "Deployment fix completed!" +print_warning "Please check the service status above." +print_warning "If any services are still failing, check their logs with:" +print_warning "docker-compose logs [service-name]" + +# Clean up temp file +rm -f /tmp/clean_db.sql + +echo "" +print_status "๐ŸŽฏ Next Steps:" +echo "1. Check service logs: docker-compose logs -f" +echo "2. Verify n8n is accessible: http://localhost:5678" +echo "3. Test API endpoints for health checks" +echo "4. Monitor for any remaining issues" diff --git a/self-improving-generator/Dockerfile b/self-improving-generator/Dockerfile index 079ec62..1edd1b6 100644 --- a/self-improving-generator/Dockerfile +++ b/self-improving-generator/Dockerfile @@ -24,7 +24,7 @@ USER app EXPOSE 8007 # Health check -HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \ CMD curl -f http://localhost:8007/health || exit 1 # Start the application diff --git a/self-improving-generator/src/main.py b/self-improving-generator/src/main.py index 6f99a27..bd88b6c 100644 --- a/self-improving-generator/src/main.py +++ b/self-improving-generator/src/main.py @@ -6,11 +6,13 @@ FastAPI application entry point for the self-improving code generator import logging import asyncio +import time from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker +from sqlalchemy.exc import OperationalError from .utils.config import get_settings, validate_configuration from .models.database_models import Base @@ -29,6 +31,35 @@ generator: SelfImprovingCodeGenerator = None engine = None SessionLocal = None +async def wait_for_database(database_url: str, max_retries: int = 30, delay: float = 2.0): + """Wait for database to be available with retry logic""" + for attempt in range(max_retries): + try: + logger.info(f"Attempting database connection (attempt {attempt + 1}/{max_retries})") + test_engine = create_engine(database_url) + + # Test the connection + with test_engine.connect() as conn: + conn.execute(text("SELECT 1")) + + logger.info("โœ… Database connection successful") + test_engine.dispose() + return True + + except OperationalError as e: + if attempt < max_retries - 1: + logger.warning(f"Database connection failed (attempt {attempt + 1}): {e}") + logger.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + else: + logger.error(f"Failed to connect to database after {max_retries} attempts: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error connecting to database: {e}") + raise + + return False + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan events""" @@ -41,6 +72,9 @@ async def lifespan(app: FastAPI): logger.info("๐Ÿš€ Starting Self-Improving Code Generator") + # Wait for database to be available + await wait_for_database(settings.database_url) + # Initialize database engine = create_engine(settings.database_url) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -63,6 +97,8 @@ async def lifespan(app: FastAPI): raise finally: logger.info("๐Ÿ›‘ Shutting down Self-Improving Code Generator") + if engine: + engine.dispose() # Create FastAPI app app = FastAPI( @@ -122,30 +158,53 @@ async def root(): @app.get("/health") async def health_check(): """Health check endpoint""" - settings = get_settings() - - health_status = { - "status": "healthy", - "service": "Self-Improving Code Generator", - "version": "1.0.0", - "timestamp": "2024-01-01T00:00:00Z", - "dependencies": { - "database": "connected" if engine else "disconnected", - "claude_api": "configured" if settings.claude_api_key else "not_configured", - "generator": "initialized" if generator else "not_initialized" + try: + settings = get_settings() + + # Test database connection + db_status = "disconnected" + if engine: + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + db_status = "connected" + except Exception as e: + logger.warning(f"Database health check failed: {e}") + db_status = "error" + + health_status = { + "status": "healthy", + "service": "Self-Improving Code Generator", + "version": "1.0.0", + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "dependencies": { + "database": db_status, + "claude_api": "configured" if settings.claude_api_key else "not_configured", + "generator": "initialized" if generator else "not_initialized" + } + } + + # Check if all dependencies are healthy + all_healthy = ( + health_status["dependencies"]["database"] == "connected" and + health_status["dependencies"]["claude_api"] == "configured" and + health_status["dependencies"]["generator"] == "initialized" + ) + + if not all_healthy: + health_status["status"] = "unhealthy" + + return health_status + + except Exception as e: + logger.error(f"Health check failed: {e}") + return { + "status": "error", + "service": "Self-Improving Code Generator", + "version": "1.0.0", + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "error": str(e) } - } - - # Check if all dependencies are healthy - all_healthy = all( - status == "connected" or status == "configured" or status == "initialized" - for status in health_status["dependencies"].values() - ) - - if not all_healthy: - health_status["status"] = "unhealthy" - - return health_status if __name__ == "__main__": import uvicorn diff --git a/services/git-integration.zip b/services/git-integration.zip index 89a82fe8611e22f1af4b60e9b6f3e8168afdfbf3..563c78491eaa3878118c15fac6b4895c7a3ed69d 100644 GIT binary patch delta 219133 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 delta 170726 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\Zfs$J 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 diff --git a/services/git-integration/package-lock.json b/services/git-integration/package-lock.json index b38e65b..d9bcb9d 100644 --- a/services/git-integration/package-lock.json +++ b/services/git-integration/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@octokit/rest": "^20.0.2", + "axios": "^1.12.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", @@ -211,6 +212,23 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -366,6 +384,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -431,6 +461,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -527,6 +566,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -653,6 +707,42 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -779,6 +869,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1317,6 +1422,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/services/git-integration/package.json b/services/git-integration/package.json index 9dc133e..39ef2cf 100644 --- a/services/git-integration/package.json +++ b/services/git-integration/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@octokit/rest": "^20.0.2", + "axios": "^1.12.2", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", diff --git a/services/git-integration/src/app.js b/services/git-integration/src/app.js index 1101279..25e6ecf 100644 --- a/services/git-integration/src/app.js +++ b/services/git-integration/src/app.js @@ -8,12 +8,20 @@ const morgan = require('morgan'); // Import database const database = require('./config/database'); +// Import services +const EnhancedDiffProcessingService = require('./services/enhanced-diff-processing.service'); + // Import routes const githubRoutes = require('./routes/github-integration.routes'); const githubOAuthRoutes = require('./routes/github-oauth'); const webhookRoutes = require('./routes/webhook.routes'); const vcsRoutes = require('./routes/vcs.routes'); +// Import new enhanced routes +const commitsRoutes = require('./routes/commits.routes'); +const oauthProvidersRoutes = require('./routes/oauth-providers.routes'); +const enhancedWebhooksRoutes = require('./routes/enhanced-webhooks.routes'); + const app = express(); const PORT = process.env.PORT || 8012; @@ -21,8 +29,21 @@ const PORT = process.env.PORT || 8012; app.use(helmet()); app.use(cors()); app.use(morgan('combined')); -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true })); +// Preserve raw body for webhook signature verification + +app.use(express.json({ + limit: '10mb', + verify: (req, res, buf) => { + req.rawBody = buf; + } +})); + +app.use(express.urlencoded({ + extended: true, + verify: (req, res, buf) => { + req.rawBody = buf; + } +})); // Session middleware app.use(session({ @@ -42,6 +63,11 @@ app.use('/api/github', githubOAuthRoutes); app.use('/api/github', webhookRoutes); app.use('/api/vcs', vcsRoutes); +// Enhanced routes +app.use('/api/commits', commitsRoutes); +app.use('/api/oauth', oauthProvidersRoutes); +app.use('/api/webhooks', enhancedWebhooksRoutes); + // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ @@ -63,7 +89,10 @@ app.get('/', (req, res) => { github: '/api/github', oauth: '/api/github/auth', webhook: '/api/github/webhook', - vcs: '/api/vcs/:provider' + vcs: '/api/vcs/:provider', + commits: '/api/commits', + oauth_providers: '/api/oauth', + enhanced_webhooks: '/api/webhooks' } }); }); @@ -99,11 +128,37 @@ process.on('SIGINT', async () => { process.exit(0); }); +// Initialize services +async function initializeServices() { + try { + // Initialize diff processing service + const diffProcessingService = new EnhancedDiffProcessingService(); + await diffProcessingService.initialize(); + + // Start background diff processing if enabled + if (process.env.ENABLE_BACKGROUND_DIFF_PROCESSING !== 'false') { + const processingInterval = parseInt(process.env.DIFF_PROCESSING_INTERVAL_MS) || 30000; + diffProcessingService.startBackgroundProcessing(processingInterval); + console.log('๐Ÿ”„ Background diff processing started'); + } + + console.log('โœ… All services initialized successfully'); + } catch (error) { + console.error('โŒ Error initializing services:', error); + } +} + // Start server -app.listen(PORT, '0.0.0.0', () => { +app.listen(PORT, '0.0.0.0', async () => { console.log(`๐Ÿš€ Git Integration Service running on port ${PORT}`); console.log(`๐Ÿ“Š Health check: http://localhost:${PORT}/health`); console.log(`๐Ÿ”— GitHub API: http://localhost:${PORT}/api/github`); + console.log(`๐Ÿ“ Commits API: http://localhost:${PORT}/api/commits`); + console.log(`๐Ÿ” OAuth API: http://localhost:${PORT}/api/oauth`); + console.log(`๐Ÿช Enhanced Webhooks: http://localhost:${PORT}/api/webhooks`); + + // Initialize services after server starts + await initializeServices(); }); module.exports = app; diff --git a/services/git-integration/src/migrations/001_github_integration.sql b/services/git-integration/src/migrations/001_github_integration.sql index ed99e51..421cb11 100644 --- a/services/git-integration/src/migrations/001_github_integration.sql +++ b/services/git-integration/src/migrations/001_github_integration.sql @@ -2,7 +2,7 @@ -- This migration adds support for GitHub repository integration -- Create table for GitHub repositories -CREATE TABLE IF NOT EXISTS github_repositories ( +CREATE TABLE IF NOT EXISTS "github_repositories@migrations/" ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), template_id UUID REFERENCES templates(id) ON DELETE CASCADE, repository_url VARCHAR(500) NOT NULL, @@ -21,13 +21,13 @@ CREATE TABLE IF NOT EXISTS github_repositories ( -- Create indexes for better performance -CREATE INDEX IF NOT EXISTS idx_github_repos_template_id ON github_repositories(template_id); -CREATE INDEX IF NOT EXISTS idx_github_repos_owner_name ON github_repositories(owner_name); +CREATE INDEX IF NOT EXISTS idx_github_repos_template_id ON "github_repositories@migrations/"(template_id); +CREATE INDEX IF NOT EXISTS idx_github_repos_owner_name ON "github_repositories@migrations/"(owner_name); CREATE INDEX IF NOT EXISTS idx_feature_mappings_feature_id ON feature_codebase_mappings(feature_id); CREATE INDEX IF NOT EXISTS idx_feature_mappings_repo_id ON feature_codebase_mappings(repository_id); -- Add trigger to update timestamp -CREATE TRIGGER update_github_repos_updated_at BEFORE UPDATE ON github_repositories +CREATE TRIGGER update_github_repos_updated_at BEFORE UPDATE ON "github_repositories@migrations/" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- ============================================= diff --git a/services/git-integration/src/migrations/002_repository_file_storage.sql b/services/git-integration/src/migrations/002_repository_file_storage.sql index f9cd1a7..bb5e81c 100644 --- a/services/git-integration/src/migrations/002_repository_file_storage.sql +++ b/services/git-integration/src/migrations/002_repository_file_storage.sql @@ -4,7 +4,7 @@ -- Create table for repository local storage tracking CREATE TABLE IF NOT EXISTS repository_storage ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, local_path TEXT NOT NULL, storage_status VARCHAR(50) DEFAULT 'pending', -- pending, downloading, completed, error total_files_count INTEGER DEFAULT 0, @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS repository_storage ( -- Create table for directory structure CREATE TABLE IF NOT EXISTS repository_directories ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, storage_id UUID REFERENCES repository_storage(id) ON DELETE CASCADE, parent_directory_id UUID REFERENCES repository_directories(id) ON DELETE CASCADE, directory_name VARCHAR(255) NOT NULL, @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS repository_directories ( -- Create table for individual files CREATE TABLE IF NOT EXISTS repository_files ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, storage_id UUID REFERENCES repository_storage(id) ON DELETE CASCADE, directory_id UUID REFERENCES repository_directories(id) ON DELETE SET NULL, filename VARCHAR(255) NOT NULL, diff --git a/services/git-integration/src/migrations/003_add_user_id_to_template_refs.sql b/services/git-integration/src/migrations/003_add_user_id_to_template_refs.sql index a8d1169..5d75153 100644 --- a/services/git-integration/src/migrations/003_add_user_id_to_template_refs.sql +++ b/services/git-integration/src/migrations/003_add_user_id_to_template_refs.sql @@ -2,12 +2,12 @@ -- This ensures we always track which user owns/initiated records tied to a template -- Add user_id to github_repositories -ALTER TABLE IF EXISTS github_repositories +ALTER TABLE IF EXISTS "github_repositories@migrations/" ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE; -- Indexes for github_repositories -CREATE INDEX IF NOT EXISTS idx_github_repos_user_id ON github_repositories(user_id); -CREATE INDEX IF NOT EXISTS idx_github_repos_template_user ON github_repositories(template_id, user_id); +CREATE INDEX IF NOT EXISTS idx_github_repos_user_id ON "github_repositories@migrations/"(user_id); +CREATE INDEX IF NOT EXISTS idx_github_repos_template_user ON "github_repositories@migrations/"(template_id, user_id); -- Add user_id to feature_codebase_mappings ALTER TABLE IF EXISTS feature_codebase_mappings diff --git a/services/git-integration/src/migrations/005_webhook_commits.sql b/services/git-integration/src/migrations/005_webhook_commits.sql index cea4482..e82825d 100644 --- a/services/git-integration/src/migrations/005_webhook_commits.sql +++ b/services/git-integration/src/migrations/005_webhook_commits.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS github_webhooks ( action VARCHAR(100), owner_name VARCHAR(120), repository_name VARCHAR(200), - repository_id UUID REFERENCES github_repositories(id) ON DELETE SET NULL, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE SET NULL, ref VARCHAR(255), before_sha VARCHAR(64), after_sha VARCHAR(64), @@ -26,7 +26,7 @@ CREATE INDEX IF NOT EXISTS idx_github_webhooks_event_type ON github_webhooks(eve -- Track commit SHA transitions per repository to detect changes over time CREATE TABLE IF NOT EXISTS repository_commit_events ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, ref VARCHAR(255), before_sha VARCHAR(64), after_sha VARCHAR(64), diff --git a/services/git-integration/src/migrations/006_commit_changes.sql b/services/git-integration/src/migrations/006_commit_changes.sql index b793717..2827d17 100644 --- a/services/git-integration/src/migrations/006_commit_changes.sql +++ b/services/git-integration/src/migrations/006_commit_changes.sql @@ -3,7 +3,7 @@ -- Per-commit details linked to an attached repository CREATE TABLE IF NOT EXISTS repository_commit_details ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, commit_sha VARCHAR(64) NOT NULL, author_name VARCHAR(200), author_email VARCHAR(320), diff --git a/services/git-integration/src/migrations/007_add_last_synced_commit.sql b/services/git-integration/src/migrations/007_add_last_synced_commit.sql index 4fe8f81..4e69b65 100644 --- a/services/git-integration/src/migrations/007_add_last_synced_commit.sql +++ b/services/git-integration/src/migrations/007_add_last_synced_commit.sql @@ -1,5 +1,5 @@ -- 007_add_last_synced_commit.sql -ALTER TABLE github_repositories +ALTER TABLE "github_repositories@migrations/" ADD COLUMN IF NOT EXISTS last_synced_commit_sha VARCHAR(64), ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMP WITH TIME ZONE; diff --git a/services/git-integration/src/migrations/009_provider_webhook_tables.sql b/services/git-integration/src/migrations/009_provider_webhook_tables.sql index e689201..afcf86c 100644 --- a/services/git-integration/src/migrations/009_provider_webhook_tables.sql +++ b/services/git-integration/src/migrations/009_provider_webhook_tables.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS gitlab_webhooks ( action TEXT, owner_name TEXT NOT NULL, repository_name TEXT NOT NULL, - repository_id UUID REFERENCES github_repositories(id), + repository_id UUID REFERENCES "github_repositories@migrations/"(id), ref TEXT, before_sha TEXT, after_sha TEXT, @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS bitbucket_webhooks ( action TEXT, owner_name TEXT NOT NULL, repository_name TEXT NOT NULL, - repository_id UUID REFERENCES github_repositories(id), + repository_id UUID REFERENCES "github_repositories@migrations/"(id), ref TEXT, before_sha TEXT, after_sha TEXT, @@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS gitea_webhooks ( action TEXT, owner_name TEXT NOT NULL, repository_name TEXT NOT NULL, - repository_id UUID REFERENCES github_repositories(id), + repository_id UUID REFERENCES "github_repositories@migrations/"(id), ref TEXT, before_sha TEXT, after_sha TEXT, diff --git a/services/git-integration/src/migrations/010_remove_template_id.sql b/services/git-integration/src/migrations/010_remove_template_id.sql index 24e8274..bd49c01 100644 --- a/services/git-integration/src/migrations/010_remove_template_id.sql +++ b/services/git-integration/src/migrations/010_remove_template_id.sql @@ -7,7 +7,7 @@ DROP INDEX IF EXISTS idx_github_repos_template_user; DROP INDEX IF EXISTS idx_feature_mappings_template_user; -- Remove template_id column from github_repositories table -ALTER TABLE IF EXISTS github_repositories +ALTER TABLE IF EXISTS "github_repositories@migrations/" DROP COLUMN IF EXISTS template_id; -- Remove template_id column from feature_codebase_mappings table diff --git a/services/git-integration/src/migrations/012_add_user_id_to_github_repositories.sql b/services/git-integration/src/migrations/012_add_user_id_to_github_repositories.sql index 46182ec..1ebff70 100644 --- a/services/git-integration/src/migrations/012_add_user_id_to_github_repositories.sql +++ b/services/git-integration/src/migrations/012_add_user_id_to_github_repositories.sql @@ -1,10 +1,10 @@ -- Migration 012: Track which user attached/downloaded a repository -- Add user_id to github_repositories to associate records with the initiating user -ALTER TABLE github_repositories +ALTER TABLE "github_repositories@migrations/" ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL; -- Helpful index for filtering user-owned repositories -CREATE INDEX IF NOT EXISTS idx_github_repositories_user_id ON github_repositories(user_id); +CREATE INDEX IF NOT EXISTS idx_github_repositories_user_id ON "github_repositories@migrations/"(user_id); diff --git a/services/git-integration/src/migrations/013_repository_commit_details.sql b/services/git-integration/src/migrations/013_repository_commit_details.sql new file mode 100644 index 0000000..067c2b1 --- /dev/null +++ b/services/git-integration/src/migrations/013_repository_commit_details.sql @@ -0,0 +1,30 @@ +-- Migration 013: Repository Commit Details and Files +-- This migration adds commit tracking tables that are missing from the current schema + +-- Per-commit details linked to an attached repository +CREATE TABLE IF NOT EXISTS repository_commit_details ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + commit_sha VARCHAR(64) NOT NULL, + author_name VARCHAR(200), + author_email VARCHAR(320), + message TEXT, + url TEXT, + committed_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Per-file changes for each commit +CREATE TABLE IF NOT EXISTS repository_commit_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + change_type VARCHAR(20) NOT NULL, -- added | modified | removed + file_path TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for performance +CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_commit_sha ON repository_commit_details(repository_id, commit_sha); +CREATE INDEX IF NOT EXISTS idx_repo_commit_created_at ON repository_commit_details(created_at); +CREATE INDEX IF NOT EXISTS idx_commit_files_commit_id ON repository_commit_files(commit_id); +CREATE INDEX IF NOT EXISTS idx_commit_files_path ON repository_commit_files(file_path); diff --git a/services/git-integration/src/migrations/014_additional_oauth_providers.sql b/services/git-integration/src/migrations/014_additional_oauth_providers.sql new file mode 100644 index 0000000..3ffd88a --- /dev/null +++ b/services/git-integration/src/migrations/014_additional_oauth_providers.sql @@ -0,0 +1,48 @@ +-- Migration 014: Additional OAuth Provider Tables +-- This migration adds OAuth token tables for GitLab, Bitbucket, and Gitea + +-- GitLab OAuth tokens +CREATE TABLE IF NOT EXISTS gitlab_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + gitlab_username TEXT, + gitlab_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Bitbucket OAuth tokens +CREATE TABLE IF NOT EXISTS bitbucket_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + bitbucket_username TEXT, + bitbucket_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Gitea OAuth tokens +CREATE TABLE IF NOT EXISTS gitea_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + gitea_username TEXT, + gitea_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Add triggers for updated_at columns +CREATE TRIGGER update_gitlab_user_tokens_updated_at BEFORE UPDATE ON gitlab_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_bitbucket_user_tokens_updated_at BEFORE UPDATE ON bitbucket_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_gitea_user_tokens_updated_at BEFORE UPDATE ON gitea_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/services/git-integration/src/migrations/015_diff_storage_system.sql b/services/git-integration/src/migrations/015_diff_storage_system.sql new file mode 100644 index 0000000..f49ab9b --- /dev/null +++ b/services/git-integration/src/migrations/015_diff_storage_system.sql @@ -0,0 +1,149 @@ +-- Migration 015: Git Diff Storage System +-- This migration adds tables for storing git diffs with size-based storage strategy + +-- Store actual diff content with size-based strategy +CREATE TABLE IF NOT EXISTS diff_contents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + file_change_id UUID REFERENCES repository_commit_files(id) ON DELETE CASCADE, + + -- Diff metadata + diff_header TEXT, -- "diff --git a/file.py b/file.py" + diff_size_bytes INTEGER NOT NULL, + + -- Storage strategy based on size + storage_type VARCHAR(20) NOT NULL, -- 'external' (all diffs stored on disk) + + -- Store reference only (path on disk) + external_storage_path TEXT NOT NULL, + external_storage_provider VARCHAR(50) DEFAULT 'local', -- 'local', 's3', 'gcs' + + -- File information + file_path TEXT NOT NULL, + change_type VARCHAR(20) NOT NULL, -- 'added', 'modified', 'deleted', 'renamed' + + -- Processing status + processing_status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processed', 'failed' + processing_error TEXT, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Diff Processing Queue (for background processing) +CREATE TABLE IF NOT EXISTS diff_processing_queue ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + + -- Processing metadata + queue_status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed' + priority INTEGER DEFAULT 0, -- Higher number = higher priority + retry_count INTEGER DEFAULT 0, + max_retries INTEGER DEFAULT 3, + + -- Git diff parameters + from_sha VARCHAR(64), + to_sha VARCHAR(64), + repo_local_path TEXT NOT NULL, + + -- Processing results + processed_at TIMESTAMP, + error_message TEXT, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Diff Statistics (for monitoring and optimization) +CREATE TABLE IF NOT EXISTS diff_statistics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + + -- Statistics period + period_start TIMESTAMP NOT NULL, + period_end TIMESTAMP NOT NULL, + + -- Count statistics + total_commits INTEGER DEFAULT 0, + total_files_changed INTEGER DEFAULT 0, + total_diffs_processed INTEGER DEFAULT 0, + + -- Size statistics + total_diff_size_bytes BIGINT DEFAULT 0, + avg_diff_size_bytes DECIMAL(10,2) DEFAULT 0, + max_diff_size_bytes BIGINT DEFAULT 0, + + -- Storage type breakdown + diffs_stored_external INTEGER DEFAULT 0, + + -- Performance metrics + avg_processing_time_ms DECIMAL(10,2) DEFAULT 0, + failed_processing_count INTEGER DEFAULT 0, + + created_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes for Performance +CREATE INDEX IF NOT EXISTS idx_diff_contents_commit_id ON diff_contents(commit_id); +CREATE INDEX IF NOT EXISTS idx_diff_contents_file_change_id ON diff_contents(file_change_id); +CREATE INDEX IF NOT EXISTS idx_diff_contents_storage_type ON diff_contents(storage_type); +CREATE INDEX IF NOT EXISTS idx_diff_contents_file_path ON diff_contents(file_path); +CREATE INDEX IF NOT EXISTS idx_diff_contents_change_type ON diff_contents(change_type); +CREATE INDEX IF NOT EXISTS idx_diff_contents_processing_status ON diff_contents(processing_status); +CREATE INDEX IF NOT EXISTS idx_diff_contents_created_at ON diff_contents(created_at); + +CREATE INDEX IF NOT EXISTS idx_diff_queue_status ON diff_processing_queue(queue_status); +CREATE INDEX IF NOT EXISTS idx_diff_queue_priority ON diff_processing_queue(priority DESC); +CREATE INDEX IF NOT EXISTS idx_diff_queue_repository_id ON diff_processing_queue(repository_id); +CREATE INDEX IF NOT EXISTS idx_diff_queue_created_at ON diff_processing_queue(created_at); + +CREATE INDEX IF NOT EXISTS idx_diff_stats_repository_id ON diff_statistics(repository_id); +CREATE INDEX IF NOT EXISTS idx_diff_stats_period ON diff_statistics(period_start, period_end); + +-- Triggers for Updated At Columns +CREATE TRIGGER update_diff_contents_updated_at BEFORE UPDATE ON diff_contents + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_diff_queue_updated_at BEFORE UPDATE ON diff_processing_queue + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Helper Functions +-- Function to get diff storage statistics for a repository +CREATE OR REPLACE FUNCTION get_repository_diff_stats(repo_id UUID, days_back INTEGER DEFAULT 30) +RETURNS TABLE ( + total_diffs BIGINT, + total_size_bytes BIGINT, + avg_size_bytes DECIMAL(10,2), + storage_breakdown JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT as total_diffs, + COALESCE(SUM(dc.diff_size_bytes), 0)::BIGINT as total_size_bytes, + COALESCE(AVG(dc.diff_size_bytes), 0)::DECIMAL(10,2) as avg_size_bytes, + jsonb_build_object( + 'external', COUNT(*) FILTER (WHERE dc.storage_type = 'external') + ) as storage_breakdown + FROM diff_contents dc + JOIN repository_commit_details rcd ON dc.commit_id = rcd.id + WHERE rcd.repository_id = repo_id + AND dc.created_at >= NOW() - INTERVAL '1 day' * days_back; +END; +$$ LANGUAGE plpgsql; + +-- Function to clean up old diff processing queue entries +CREATE OR REPLACE FUNCTION cleanup_old_diff_queue_entries(days_back INTEGER DEFAULT 7) +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM diff_processing_queue + WHERE created_at < NOW() - INTERVAL '1 day' * days_back + AND queue_status IN ('completed', 'failed'); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; diff --git a/services/git-integration/src/migrations/016_missing_columns_and_indexes.sql b/services/git-integration/src/migrations/016_missing_columns_and_indexes.sql new file mode 100644 index 0000000..3e1757b --- /dev/null +++ b/services/git-integration/src/migrations/016_missing_columns_and_indexes.sql @@ -0,0 +1,25 @@ +-- Migration 016: Missing Columns and Additional Indexes +-- This migration adds missing columns and indexes from the provided migrations + +-- Add missing column to github_repositories if it doesn't exist +ALTER TABLE "github_repositories@migrations/" +ADD COLUMN IF NOT EXISTS last_synced_commit_sha VARCHAR(64); + +-- Add missing ID column to repository_files if it doesn't exist +ALTER TABLE repository_files +ADD COLUMN IF NOT EXISTS id UUID PRIMARY KEY DEFAULT uuid_generate_v4(); + +-- Additional indexes for better performance that were missing +CREATE INDEX IF NOT EXISTS idx_repo_directories_level ON repository_directories(level); +CREATE INDEX IF NOT EXISTS idx_repo_directories_relative_path ON repository_directories(relative_path); + +CREATE INDEX IF NOT EXISTS idx_repo_files_extension ON repository_files(file_extension); +CREATE INDEX IF NOT EXISTS idx_repo_files_filename ON repository_files(filename); +CREATE INDEX IF NOT EXISTS idx_repo_files_relative_path ON repository_files(relative_path); +CREATE INDEX IF NOT EXISTS idx_repo_files_is_binary ON repository_files(is_binary); + +-- Webhook indexes that might be missing +CREATE INDEX IF NOT EXISTS idx_bitbucket_webhooks_event_type ON bitbucket_webhooks(event_type); +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_repository_id ON gitea_webhooks(repository_id); +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_created_at ON gitea_webhooks(created_at); +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_event_type ON gitea_webhooks(event_type); diff --git a/services/git-integration/src/migrations/017_complete_schema_from_provided_migrations.sql b/services/git-integration/src/migrations/017_complete_schema_from_provided_migrations.sql new file mode 100644 index 0000000..3b59037 --- /dev/null +++ b/services/git-integration/src/migrations/017_complete_schema_from_provided_migrations.sql @@ -0,0 +1,569 @@ +-- Migration 017: Complete Schema from Provided Migrations +-- This migration creates all tables from the provided 001_github_integration.sql and 002_diff_storage.sql files + +-- ============================================= +-- Core Repository Tables +-- ============================================= + +-- Create table for GitHub repositories (enhanced version from provided migration) +CREATE TABLE IF NOT EXISTS "github_repositories@migrations/" ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + template_id UUID, -- References templates(id) but table may not exist + repository_url VARCHAR(500) NOT NULL, + repository_name VARCHAR(200) NOT NULL, + owner_name VARCHAR(100) NOT NULL, + branch_name VARCHAR(100) DEFAULT 'main', + is_public BOOLEAN DEFAULT true, + requires_auth BOOLEAN DEFAULT false, + last_synced_at TIMESTAMP, + sync_status VARCHAR(50) DEFAULT 'pending', + metadata JSONB, + codebase_analysis JSONB, + last_synced_commit_sha VARCHAR(64), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================= +-- Repository File Storage Tables +-- ============================================= + +-- Create table for repository local storage tracking +CREATE TABLE IF NOT EXISTS repository_storage ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + local_path TEXT NOT NULL, + storage_status VARCHAR(50) DEFAULT 'pending', -- pending, downloading, completed, error + total_files_count INTEGER DEFAULT 0, + total_directories_count INTEGER DEFAULT 0, + total_size_bytes BIGINT DEFAULT 0, + download_started_at TIMESTAMP, + download_completed_at TIMESTAMP, + last_verified_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(repository_id) +); + +-- Create table for directory structure +CREATE TABLE IF NOT EXISTS repository_directories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + storage_id UUID REFERENCES repository_storage(id) ON DELETE CASCADE, + parent_directory_id UUID REFERENCES repository_directories(id) ON DELETE CASCADE, + directory_name VARCHAR(255) NOT NULL, + relative_path TEXT NOT NULL, -- path from repository root + absolute_path TEXT NOT NULL, -- full local filesystem path + level INTEGER DEFAULT 0, -- depth in hierarchy (0 = root) + files_count INTEGER DEFAULT 0, + subdirectories_count INTEGER DEFAULT 0, + total_size_bytes BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Create table for individual files +CREATE TABLE IF NOT EXISTS repository_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + storage_id UUID REFERENCES repository_storage(id) ON DELETE CASCADE, + directory_id UUID REFERENCES repository_directories(id) ON DELETE SET NULL, + filename VARCHAR(255) NOT NULL, + file_extension VARCHAR(50), + relative_path TEXT NOT NULL, -- path from repository root + absolute_path TEXT NOT NULL, -- full local filesystem path + file_size_bytes BIGINT DEFAULT 0, + file_hash VARCHAR(64), -- SHA-256 hash for integrity + mime_type VARCHAR(100), + is_binary BOOLEAN DEFAULT false, + encoding VARCHAR(50) DEFAULT 'utf-8', + github_sha VARCHAR(40), -- GitHub blob SHA for tracking changes + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================= +-- Webhook Tables +-- ============================================= + +-- GitHub webhooks table +CREATE TABLE IF NOT EXISTS github_webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + delivery_id VARCHAR(120), + event_type VARCHAR(100) NOT NULL, + action VARCHAR(100), + owner_name VARCHAR(120), + repository_name VARCHAR(200), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE SET NULL, + ref VARCHAR(255), + before_sha VARCHAR(64), + after_sha VARCHAR(64), + commit_count INTEGER, + payload JSONB NOT NULL, + processed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- GitLab webhooks table +CREATE TABLE IF NOT EXISTS gitlab_webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + delivery_id TEXT, + event_type TEXT NOT NULL, + action TEXT, + owner_name TEXT NOT NULL, + repository_name TEXT NOT NULL, + repository_id UUID REFERENCES "github_repositories@migrations/"(id), + ref TEXT, + before_sha TEXT, + after_sha TEXT, + commit_count INTEGER DEFAULT 0, + payload JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Bitbucket webhooks table +CREATE TABLE IF NOT EXISTS bitbucket_webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + delivery_id TEXT, + event_type TEXT NOT NULL, + action TEXT, + owner_name TEXT NOT NULL, + repository_name TEXT NOT NULL, + repository_id UUID REFERENCES "github_repositories@migrations/"(id), + ref TEXT, + before_sha TEXT, + after_sha TEXT, + commit_count INTEGER DEFAULT 0, + payload JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Gitea webhooks table +CREATE TABLE IF NOT EXISTS gitea_webhooks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + delivery_id TEXT, + event_type TEXT NOT NULL, + action TEXT, + owner_name TEXT NOT NULL, + repository_name TEXT NOT NULL, + repository_id UUID REFERENCES "github_repositories@migrations/"(id), + ref TEXT, + before_sha TEXT, + after_sha TEXT, + commit_count INTEGER DEFAULT 0, + payload JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ============================================= +-- Commit Tracking Tables +-- ============================================= + +-- Per-commit details linked to an attached repository +CREATE TABLE IF NOT EXISTS repository_commit_details ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + commit_sha VARCHAR(64) NOT NULL, + author_name VARCHAR(200), + author_email VARCHAR(320), + message TEXT, + url TEXT, + committed_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Per-file changes for each commit +CREATE TABLE IF NOT EXISTS repository_commit_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + change_type VARCHAR(20) NOT NULL, -- added | modified | removed + file_path TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================= +-- OAuth Token Tables +-- ============================================= + +-- GitHub OAuth tokens +CREATE TABLE IF NOT EXISTS github_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + github_username VARCHAR(100) NOT NULL, + github_user_id INTEGER NOT NULL, + scopes JSONB, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- GitLab OAuth tokens +CREATE TABLE IF NOT EXISTS gitlab_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + gitlab_username TEXT, + gitlab_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Bitbucket OAuth tokens +CREATE TABLE IF NOT EXISTS bitbucket_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + bitbucket_username TEXT, + bitbucket_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Gitea OAuth tokens +CREATE TABLE IF NOT EXISTS gitea_user_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + access_token TEXT NOT NULL, + gitea_username TEXT, + gitea_user_id TEXT, + scopes JSONB, + expires_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ============================================= +-- Diff Storage Tables (from 002_diff_storage.sql) +-- ============================================= + +-- Store actual diff content with size-based strategy +CREATE TABLE IF NOT EXISTS diff_contents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + file_change_id UUID REFERENCES repository_commit_files(id) ON DELETE CASCADE, + + -- Diff metadata + diff_header TEXT, -- "diff --git a/file.py b/file.py" + diff_size_bytes INTEGER NOT NULL, + + -- Storage strategy based on size + storage_type VARCHAR(20) NOT NULL, -- 'external' (all diffs stored on disk) + + -- Store reference only (path on disk) + external_storage_path TEXT NOT NULL, + external_storage_provider VARCHAR(50) DEFAULT 'local', -- 'local', 's3', 'gcs' + + -- File information + file_path TEXT NOT NULL, + change_type VARCHAR(20) NOT NULL, -- 'added', 'modified', 'deleted', 'renamed' + + -- Processing status + processing_status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processed', 'failed' + processing_error TEXT, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Diff Processing Queue (for background processing) +CREATE TABLE IF NOT EXISTS diff_processing_queue ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + commit_id UUID REFERENCES repository_commit_details(id) ON DELETE CASCADE, + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + + -- Processing metadata + queue_status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed' + priority INTEGER DEFAULT 0, -- Higher number = higher priority + retry_count INTEGER DEFAULT 0, + max_retries INTEGER DEFAULT 3, + + -- Git diff parameters + from_sha VARCHAR(64), + to_sha VARCHAR(64), + repo_local_path TEXT NOT NULL, + + -- Processing results + processed_at TIMESTAMP, + error_message TEXT, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Diff Statistics (for monitoring and optimization) +CREATE TABLE IF NOT EXISTS diff_statistics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + repository_id UUID REFERENCES "github_repositories@migrations/"(id) ON DELETE CASCADE, + + -- Statistics period + period_start TIMESTAMP NOT NULL, + period_end TIMESTAMP NOT NULL, + + -- Count statistics + total_commits INTEGER DEFAULT 0, + total_files_changed INTEGER DEFAULT 0, + total_diffs_processed INTEGER DEFAULT 0, + + -- Size statistics + total_diff_size_bytes BIGINT DEFAULT 0, + avg_diff_size_bytes DECIMAL(10,2) DEFAULT 0, + max_diff_size_bytes BIGINT DEFAULT 0, + + -- Storage type breakdown + diffs_stored_external INTEGER DEFAULT 0, + + -- Performance metrics + avg_processing_time_ms DECIMAL(10,2) DEFAULT 0, + failed_processing_count INTEGER DEFAULT 0, + + created_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================= +-- Indexes for Performance +-- ============================================= + +-- GitHub repositories indexes +CREATE INDEX IF NOT EXISTS idx_github_repos_template_id ON "github_repositories@migrations/"(template_id); +CREATE INDEX IF NOT EXISTS idx_github_repos_owner_name ON "github_repositories@migrations/"(owner_name); + +-- Repository storage indexes +CREATE INDEX IF NOT EXISTS idx_repository_storage_repo_id ON repository_storage(repository_id); +CREATE INDEX IF NOT EXISTS idx_repository_storage_status ON repository_storage(storage_status); + +-- Repository directories indexes +CREATE INDEX IF NOT EXISTS idx_repo_directories_repo_id ON repository_directories(repository_id); +CREATE INDEX IF NOT EXISTS idx_repo_directories_parent_id ON repository_directories(parent_directory_id); +CREATE INDEX IF NOT EXISTS idx_repo_directories_storage_id ON repository_directories(storage_id); +CREATE INDEX IF NOT EXISTS idx_repo_directories_level ON repository_directories(level); +CREATE INDEX IF NOT EXISTS idx_repo_directories_relative_path ON repository_directories(relative_path); + +-- Repository files indexes +CREATE INDEX IF NOT EXISTS idx_repo_files_repo_id ON repository_files(repository_id); +CREATE INDEX IF NOT EXISTS idx_repo_files_directory_id ON repository_files(directory_id); +CREATE INDEX IF NOT EXISTS idx_repo_files_storage_id ON repository_files(storage_id); +CREATE INDEX IF NOT EXISTS idx_repo_files_extension ON repository_files(file_extension); +CREATE INDEX IF NOT EXISTS idx_repo_files_filename ON repository_files(filename); +CREATE INDEX IF NOT EXISTS idx_repo_files_relative_path ON repository_files(relative_path); +CREATE INDEX IF NOT EXISTS idx_repo_files_is_binary ON repository_files(is_binary); + +-- GitHub webhooks indexes +CREATE INDEX IF NOT EXISTS idx_github_webhooks_delivery_id ON github_webhooks(delivery_id); +CREATE INDEX IF NOT EXISTS idx_github_webhooks_repo ON github_webhooks(owner_name, repository_name); +CREATE INDEX IF NOT EXISTS idx_github_webhooks_event_type ON github_webhooks(event_type); + +-- GitLab webhooks indexes +CREATE INDEX IF NOT EXISTS idx_gitlab_webhooks_repository_id ON gitlab_webhooks(repository_id); +CREATE INDEX IF NOT EXISTS idx_gitlab_webhooks_created_at ON gitlab_webhooks(created_at); +CREATE INDEX IF NOT EXISTS idx_gitlab_webhooks_event_type ON gitlab_webhooks(event_type); + +-- Bitbucket webhooks indexes +CREATE INDEX IF NOT EXISTS idx_bitbucket_webhooks_repository_id ON bitbucket_webhooks(repository_id); +CREATE INDEX IF NOT EXISTS idx_bitbucket_webhooks_created_at ON bitbucket_webhooks(created_at); +CREATE INDEX IF NOT EXISTS idx_bitbucket_webhooks_event_type ON bitbucket_webhooks(event_type); + +-- Gitea webhooks indexes +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_repository_id ON gitea_webhooks(repository_id); +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_created_at ON gitea_webhooks(created_at); +CREATE INDEX IF NOT EXISTS idx_gitea_webhooks_event_type ON gitea_webhooks(event_type); + +-- Commit details indexes +CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_commit_sha ON repository_commit_details(repository_id, commit_sha); +CREATE INDEX IF NOT EXISTS idx_repo_commit_created_at ON repository_commit_details(created_at); + +-- Commit files indexes +CREATE INDEX IF NOT EXISTS idx_commit_files_commit_id ON repository_commit_files(commit_id); +CREATE INDEX IF NOT EXISTS idx_commit_files_path ON repository_commit_files(file_path); + +-- OAuth token indexes +CREATE INDEX IF NOT EXISTS idx_github_user_tokens_github_username ON github_user_tokens(github_username); + +-- Diff contents indexes +CREATE INDEX IF NOT EXISTS idx_diff_contents_commit_id ON diff_contents(commit_id); +CREATE INDEX IF NOT EXISTS idx_diff_contents_file_change_id ON diff_contents(file_change_id); +CREATE INDEX IF NOT EXISTS idx_diff_contents_storage_type ON diff_contents(storage_type); +CREATE INDEX IF NOT EXISTS idx_diff_contents_file_path ON diff_contents(file_path); +CREATE INDEX IF NOT EXISTS idx_diff_contents_change_type ON diff_contents(change_type); +CREATE INDEX IF NOT EXISTS idx_diff_contents_processing_status ON diff_contents(processing_status); +CREATE INDEX IF NOT EXISTS idx_diff_contents_created_at ON diff_contents(created_at); + +-- Processing queue indexes +CREATE INDEX IF NOT EXISTS idx_diff_queue_status ON diff_processing_queue(queue_status); +CREATE INDEX IF NOT EXISTS idx_diff_queue_priority ON diff_processing_queue(priority DESC); +CREATE INDEX IF NOT EXISTS idx_diff_queue_repository_id ON diff_processing_queue(repository_id); +CREATE INDEX IF NOT EXISTS idx_diff_queue_created_at ON diff_processing_queue(created_at); + +-- Statistics indexes +CREATE INDEX IF NOT EXISTS idx_diff_stats_repository_id ON diff_statistics(repository_id); +CREATE INDEX IF NOT EXISTS idx_diff_stats_period ON diff_statistics(period_start, period_end); + +-- ============================================= +-- Triggers for Updated At Columns +-- ============================================= + +-- Create update function if it doesn't exist +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- GitHub repositories trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_github_repos_updated_at' + ) THEN + CREATE TRIGGER update_github_repos_updated_at BEFORE UPDATE ON "github_repositories@migrations/" + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Repository storage trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_repository_storage_updated_at' + ) THEN + CREATE TRIGGER update_repository_storage_updated_at BEFORE UPDATE ON repository_storage + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Repository directories trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_repository_directories_updated_at' + ) THEN + CREATE TRIGGER update_repository_directories_updated_at BEFORE UPDATE ON repository_directories + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Repository files trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_repository_files_updated_at' + ) THEN + CREATE TRIGGER update_repository_files_updated_at BEFORE UPDATE ON repository_files + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- GitHub webhooks trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_github_webhooks_updated_at' + ) THEN + CREATE TRIGGER update_github_webhooks_updated_at BEFORE UPDATE ON github_webhooks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- GitLab user tokens trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_gitlab_user_tokens_updated_at' + ) THEN + CREATE TRIGGER update_gitlab_user_tokens_updated_at BEFORE UPDATE ON gitlab_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Bitbucket user tokens trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_bitbucket_user_tokens_updated_at' + ) THEN + CREATE TRIGGER update_bitbucket_user_tokens_updated_at BEFORE UPDATE ON bitbucket_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Gitea user tokens trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_gitea_user_tokens_updated_at' + ) THEN + CREATE TRIGGER update_gitea_user_tokens_updated_at BEFORE UPDATE ON gitea_user_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Diff contents trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_diff_contents_updated_at' + ) THEN + CREATE TRIGGER update_diff_contents_updated_at BEFORE UPDATE ON diff_contents + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Processing queue trigger +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'update_diff_queue_updated_at' + ) THEN + CREATE TRIGGER update_diff_queue_updated_at BEFORE UPDATE ON diff_processing_queue + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- ============================================= +-- Helper Functions +-- ============================================= + +-- Function to get diff storage statistics for a repository +CREATE OR REPLACE FUNCTION get_repository_diff_stats(repo_id UUID, days_back INTEGER DEFAULT 30) +RETURNS TABLE ( + total_diffs BIGINT, + total_size_bytes BIGINT, + avg_size_bytes DECIMAL(10,2), + storage_breakdown JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::BIGINT as total_diffs, + COALESCE(SUM(dc.diff_size_bytes), 0)::BIGINT as total_size_bytes, + COALESCE(AVG(dc.diff_size_bytes), 0)::DECIMAL(10,2) as avg_size_bytes, + jsonb_build_object( + 'external', COUNT(*) FILTER (WHERE dc.storage_type = 'external') + ) as storage_breakdown + FROM diff_contents dc + JOIN repository_commit_details rcd ON dc.commit_id = rcd.id + WHERE rcd.repository_id = repo_id + AND dc.created_at >= NOW() - INTERVAL '1 day' * days_back; +END; +$$ LANGUAGE plpgsql; + +-- Function to clean up old diff processing queue entries +CREATE OR REPLACE FUNCTION cleanup_old_diff_queue_entries(days_back INTEGER DEFAULT 7) +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM diff_processing_queue + WHERE created_at < NOW() - INTERVAL '1 day' * days_back + AND queue_status IN ('completed', 'failed'); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; diff --git a/services/git-integration/src/models/commit-details.model.js b/services/git-integration/src/models/commit-details.model.js new file mode 100644 index 0000000..d6fa2d5 --- /dev/null +++ b/services/git-integration/src/models/commit-details.model.js @@ -0,0 +1,79 @@ +const { v4: uuidv4 } = require('uuid'); +const database = require('../config/database'); + +class CommitDetailsModel { + constructor() { + this.db = database; + } + + async createCommitDetail(commitData) { + const { + repository_id, + commit_sha, + author_name, + author_email, + message, + url, + committed_at + } = commitData; + + const query = ` + INSERT INTO repository_commit_details ( + id, repository_id, commit_sha, author_name, author_email, + message, url, committed_at, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (repository_id, commit_sha) + DO UPDATE SET + author_name = EXCLUDED.author_name, + author_email = EXCLUDED.author_email, + message = EXCLUDED.message, + url = EXCLUDED.url, + committed_at = EXCLUDED.committed_at + RETURNING * + `; + + const values = [ + uuidv4(), + repository_id, + commit_sha, + author_name, + author_email, + message, + url, + committed_at || new Date() + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getCommitsByRepository(repository_id, limit = 50, offset = 0) { + const query = ` + SELECT * FROM repository_commit_details + WHERE repository_id = $1 + ORDER BY committed_at DESC + LIMIT $2 OFFSET $3 + `; + + const result = await this.db.query(query, [repository_id, limit, offset]); + return result.rows; + } + + async getCommitBySha(repository_id, commit_sha) { + const query = ` + SELECT * FROM repository_commit_details + WHERE repository_id = $1 AND commit_sha = $2 + `; + + const result = await this.db.query(query, [repository_id, commit_sha]); + return result.rows[0]; + } + + async deleteCommitsByRepository(repository_id) { + const query = `DELETE FROM repository_commit_details WHERE repository_id = $1`; + const result = await this.db.query(query, [repository_id]); + return result.rowCount; + } +} + +module.exports = CommitDetailsModel; diff --git a/services/git-integration/src/models/commit-files.model.js b/services/git-integration/src/models/commit-files.model.js new file mode 100644 index 0000000..4acadf3 --- /dev/null +++ b/services/git-integration/src/models/commit-files.model.js @@ -0,0 +1,102 @@ +const { v4: uuidv4 } = require('uuid'); +const database = require('../config/database'); + +class CommitFilesModel { + constructor() { + this.db = database; + } + + async createCommitFile(fileData) { + const { + commit_id, + change_type, + file_path + } = fileData; + + const query = ` + INSERT INTO repository_commit_files ( + id, commit_id, change_type, file_path, created_at + ) VALUES ($1, $2, $3, $4, NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + commit_id, + change_type, + file_path + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async createMultipleCommitFiles(commit_id, files) { + if (!files || files.length === 0) return []; + + const values = []; + const placeholders = []; + + files.forEach((file, index) => { + const baseIndex = index * 4; + placeholders.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4}, NOW())`); + values.push(uuidv4(), commit_id, file.change_type, file.file_path); + }); + + const query = ` + INSERT INTO repository_commit_files ( + id, commit_id, change_type, file_path, created_at + ) VALUES ${placeholders.join(', ')} + RETURNING * + `; + + const result = await this.db.query(query, values); + return result.rows; + } + + async getFilesByCommit(commit_id) { + const query = ` + SELECT * FROM repository_commit_files + WHERE commit_id = $1 + ORDER BY file_path + `; + + const result = await this.db.query(query, [commit_id]); + return result.rows; + } + + async getFilesByRepository(repository_id, limit = 100, offset = 0) { + const query = ` + SELECT rcf.*, rcd.commit_sha, rcd.committed_at + FROM repository_commit_files rcf + JOIN repository_commit_details rcd ON rcf.commit_id = rcd.id + WHERE rcd.repository_id = $1 + ORDER BY rcd.committed_at DESC, rcf.file_path + LIMIT $2 OFFSET $3 + `; + + const result = await this.db.query(query, [repository_id, limit, offset]); + return result.rows; + } + + async getFileChangesByPath(repository_id, file_path) { + const query = ` + SELECT rcf.*, rcd.commit_sha, rcd.committed_at, rcd.author_name + FROM repository_commit_files rcf + JOIN repository_commit_details rcd ON rcf.commit_id = rcd.id + WHERE rcd.repository_id = $1 AND rcf.file_path = $2 + ORDER BY rcd.committed_at DESC + `; + + const result = await this.db.query(query, [repository_id, file_path]); + return result.rows; + } + + async deleteFilesByCommit(commit_id) { + const query = `DELETE FROM repository_commit_files WHERE commit_id = $1`; + const result = await this.db.query(query, [commit_id]); + return result.rowCount; + } +} + +module.exports = CommitFilesModel; diff --git a/services/git-integration/src/models/diff-storage.model.js b/services/git-integration/src/models/diff-storage.model.js new file mode 100644 index 0000000..8a0651f --- /dev/null +++ b/services/git-integration/src/models/diff-storage.model.js @@ -0,0 +1,226 @@ +const { v4: uuidv4 } = require('uuid'); +const database = require('../config/database'); + +class DiffStorageModel { + constructor() { + this.db = database; + } + + async createDiffContent(diffData) { + const { + commit_id, + file_change_id, + diff_header, + diff_size_bytes, + storage_type = 'external', + external_storage_path, + external_storage_provider = 'local', + file_path, + change_type, + processing_status = 'pending' + } = diffData; + + const query = ` + INSERT INTO diff_contents ( + id, commit_id, file_change_id, diff_header, diff_size_bytes, + storage_type, external_storage_path, external_storage_provider, + file_path, change_type, processing_status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + commit_id, + file_change_id, + diff_header, + diff_size_bytes, + storage_type, + external_storage_path, + external_storage_provider, + file_path, + change_type, + processing_status + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async updateProcessingStatus(diff_id, status, error_message = null) { + const query = ` + UPDATE diff_contents + SET processing_status = $2, processing_error = $3, updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [diff_id, status, error_message]); + return result.rows[0]; + } + + async getDiffsByCommit(commit_id) { + const query = ` + SELECT * FROM diff_contents + WHERE commit_id = $1 + ORDER BY file_path + `; + + const result = await this.db.query(query, [commit_id]); + return result.rows; + } + + async getDiffsByRepository(repository_id, limit = 50, offset = 0) { + const query = ` + SELECT dc.*, rcd.commit_sha, rcd.committed_at + FROM diff_contents dc + JOIN repository_commit_details rcd ON dc.commit_id = rcd.id + WHERE rcd.repository_id = $1 + ORDER BY rcd.committed_at DESC, dc.file_path + LIMIT $2 OFFSET $3 + `; + + const result = await this.db.query(query, [repository_id, limit, offset]); + return result.rows; + } + + async getDiffById(diff_id) { + const query = `SELECT * FROM diff_contents WHERE id = $1`; + const result = await this.db.query(query, [diff_id]); + return result.rows[0]; + } + + // Diff Processing Queue methods + async addToProcessingQueue(queueData) { + const { + commit_id, + repository_id, + queue_status = 'pending', + priority = 0, + from_sha, + to_sha, + repo_local_path + } = queueData; + + const query = ` + INSERT INTO diff_processing_queue ( + id, commit_id, repository_id, queue_status, priority, + from_sha, to_sha, repo_local_path, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + commit_id, + repository_id, + queue_status, + priority, + from_sha, + to_sha, + repo_local_path + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getNextQueueItem() { + const query = ` + SELECT * FROM diff_processing_queue + WHERE queue_status = 'pending' + ORDER BY priority DESC, created_at ASC + LIMIT 1 + `; + + const result = await this.db.query(query); + return result.rows[0]; + } + + async updateQueueStatus(queue_id, status, error_message = null) { + const query = ` + UPDATE diff_processing_queue + SET queue_status = $2, error_message = $3, + processed_at = CASE WHEN $2 IN ('completed', 'failed') THEN NOW() ELSE processed_at END, + updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [queue_id, status, error_message]); + return result.rows[0]; + } + + async incrementRetryCount(queue_id) { + const query = ` + UPDATE diff_processing_queue + SET retry_count = retry_count + 1, updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [queue_id]); + return result.rows[0]; + } + + // Statistics methods + async createDiffStatistics(statsData) { + const { + repository_id, + period_start, + period_end, + total_commits = 0, + total_files_changed = 0, + total_diffs_processed = 0, + total_diff_size_bytes = 0, + avg_diff_size_bytes = 0, + max_diff_size_bytes = 0, + diffs_stored_external = 0, + avg_processing_time_ms = 0, + failed_processing_count = 0 + } = statsData; + + const query = ` + INSERT INTO diff_statistics ( + id, repository_id, period_start, period_end, total_commits, + total_files_changed, total_diffs_processed, total_diff_size_bytes, + avg_diff_size_bytes, max_diff_size_bytes, diffs_stored_external, + avg_processing_time_ms, failed_processing_count, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + repository_id, + period_start, + period_end, + total_commits, + total_files_changed, + total_diffs_processed, + total_diff_size_bytes, + avg_diff_size_bytes, + max_diff_size_bytes, + diffs_stored_external, + avg_processing_time_ms, + failed_processing_count + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getRepositoryDiffStats(repository_id, days_back = 30) { + const query = `SELECT * FROM get_repository_diff_stats($1, $2)`; + const result = await this.db.query(query, [repository_id, days_back]); + return result.rows[0]; + } + + async cleanupOldQueueEntries(days_back = 7) { + const query = `SELECT cleanup_old_diff_queue_entries($1)`; + const result = await this.db.query(query, [days_back]); + return result.rows[0].cleanup_old_diff_queue_entries; + } +} + +module.exports = DiffStorageModel; diff --git a/services/git-integration/src/models/oauth-tokens.model.js b/services/git-integration/src/models/oauth-tokens.model.js new file mode 100644 index 0000000..7ab54e3 --- /dev/null +++ b/services/git-integration/src/models/oauth-tokens.model.js @@ -0,0 +1,227 @@ +const { v4: uuidv4 } = require('uuid'); +const database = require('../config/database'); + +class OAuthTokensModel { + constructor() { + this.db = database; + } + + // GitLab OAuth tokens + async createGitLabToken(tokenData) { + const { + access_token, + gitlab_username, + gitlab_user_id, + scopes, + expires_at + } = tokenData; + + const query = ` + INSERT INTO gitlab_user_tokens ( + id, access_token, gitlab_username, gitlab_user_id, + scopes, expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + access_token, + gitlab_username, + gitlab_user_id, + scopes, + expires_at + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getGitLabTokenByUsername(gitlab_username) { + const query = ` + SELECT * FROM gitlab_user_tokens + WHERE gitlab_username = $1 + ORDER BY created_at DESC + LIMIT 1 + `; + + const result = await this.db.query(query, [gitlab_username]); + return result.rows[0]; + } + + async updateGitLabToken(token_id, tokenData) { + const { + access_token, + scopes, + expires_at + } = tokenData; + + const query = ` + UPDATE gitlab_user_tokens + SET access_token = $2, scopes = $3, expires_at = $4, updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [token_id, access_token, scopes, expires_at]); + return result.rows[0]; + } + + // Bitbucket OAuth tokens + async createBitbucketToken(tokenData) { + const { + access_token, + bitbucket_username, + bitbucket_user_id, + scopes, + expires_at + } = tokenData; + + const query = ` + INSERT INTO bitbucket_user_tokens ( + id, access_token, bitbucket_username, bitbucket_user_id, + scopes, expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + access_token, + bitbucket_username, + bitbucket_user_id, + scopes, + expires_at + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getBitbucketTokenByUsername(bitbucket_username) { + const query = ` + SELECT * FROM bitbucket_user_tokens + WHERE bitbucket_username = $1 + ORDER BY created_at DESC + LIMIT 1 + `; + + const result = await this.db.query(query, [bitbucket_username]); + return result.rows[0]; + } + + async updateBitbucketToken(token_id, tokenData) { + const { + access_token, + scopes, + expires_at + } = tokenData; + + const query = ` + UPDATE bitbucket_user_tokens + SET access_token = $2, scopes = $3, expires_at = $4, updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [token_id, access_token, scopes, expires_at]); + return result.rows[0]; + } + + // Gitea OAuth tokens + async createGiteaToken(tokenData) { + const { + access_token, + gitea_username, + gitea_user_id, + scopes, + expires_at + } = tokenData; + + const query = ` + INSERT INTO gitea_user_tokens ( + id, access_token, gitea_username, gitea_user_id, + scopes, expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + access_token, + gitea_username, + gitea_user_id, + scopes, + expires_at + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async getGiteaTokenByUsername(gitea_username) { + const query = ` + SELECT * FROM gitea_user_tokens + WHERE gitea_username = $1 + ORDER BY created_at DESC + LIMIT 1 + `; + + const result = await this.db.query(query, [gitea_username]); + return result.rows[0]; + } + + async updateGiteaToken(token_id, tokenData) { + const { + access_token, + scopes, + expires_at + } = tokenData; + + const query = ` + UPDATE gitea_user_tokens + SET access_token = $2, scopes = $3, expires_at = $4, updated_at = NOW() + WHERE id = $1 + RETURNING * + `; + + const result = await this.db.query(query, [token_id, access_token, scopes, expires_at]); + return result.rows[0]; + } + + // Generic methods for all providers + async deleteExpiredTokens() { + const queries = [ + 'DELETE FROM gitlab_user_tokens WHERE expires_at < NOW()', + 'DELETE FROM bitbucket_user_tokens WHERE expires_at < NOW()', + 'DELETE FROM gitea_user_tokens WHERE expires_at < NOW()' + ]; + + let totalDeleted = 0; + for (const query of queries) { + const result = await this.db.query(query); + totalDeleted += result.rowCount; + } + + return totalDeleted; + } + + async getAllTokensByProvider(provider) { + const tableMap = { + 'gitlab': 'gitlab_user_tokens', + 'bitbucket': 'bitbucket_user_tokens', + 'gitea': 'gitea_user_tokens' + }; + + const tableName = tableMap[provider]; + if (!tableName) { + throw new Error(`Unsupported provider: ${provider}`); + } + + const query = `SELECT * FROM ${tableName} ORDER BY created_at DESC`; + const result = await this.db.query(query); + return result.rows; + } +} + +module.exports = OAuthTokensModel; diff --git a/services/git-integration/src/routes/commits.routes.js b/services/git-integration/src/routes/commits.routes.js new file mode 100644 index 0000000..81d6822 --- /dev/null +++ b/services/git-integration/src/routes/commits.routes.js @@ -0,0 +1,273 @@ +const express = require('express'); +const router = express.Router(); +const CommitTrackingService = require('../services/commit-tracking.service'); +const DiffStorageModel = require('../models/diff-storage.model'); +const EnhancedDiffProcessingService = require('../services/enhanced-diff-processing.service'); + +const commitTrackingService = new CommitTrackingService(); +const diffStorageModel = new DiffStorageModel(); +const diffProcessingService = new EnhancedDiffProcessingService(); + +/** + * GET /api/commits/repository/:repositoryId + * Get commit history for a repository + */ +router.get('/repository/:repositoryId', async (req, res) => { + try { + const { repositoryId } = req.params; + const { + limit = 50, + offset = 0, + include_files = false + } = req.query; + + const options = { + limit: parseInt(limit), + offset: parseInt(offset), + include_files: include_files === 'true' + }; + + const commits = await commitTrackingService.getCommitHistory(repositoryId, options); + + res.json({ + success: true, + data: commits, + pagination: { + limit: options.limit, + offset: options.offset, + total: commits.length + } + }); + } catch (error) { + console.error('Error getting commit history:', error); + res.status(500).json({ + success: false, + error: 'Failed to get commit history', + message: error.message + }); + } +}); + +/** + * GET /api/commits/:commitId/files + * Get file changes for a specific commit + */ +router.get('/:commitId/files', async (req, res) => { + try { + const { commitId } = req.params; + + const files = await commitTrackingService.commitFilesModel.getFilesByCommit(commitId); + + res.json({ + success: true, + data: files + }); + } catch (error) { + console.error('Error getting commit files:', error); + res.status(500).json({ + success: false, + error: 'Failed to get commit files', + message: error.message + }); + } +}); + +/** + * GET /api/commits/repository/:repositoryId/file-history + * Get file change history for a specific file + */ +router.get('/repository/:repositoryId/file-history', async (req, res) => { + try { + const { repositoryId } = req.params; + const { file_path } = req.query; + + if (!file_path) { + return res.status(400).json({ + success: false, + error: 'file_path query parameter is required' + }); + } + + const history = await commitTrackingService.getFileHistory(repositoryId, file_path); + + res.json({ + success: true, + data: history + }); + } catch (error) { + console.error('Error getting file history:', error); + res.status(500).json({ + success: false, + error: 'Failed to get file history', + message: error.message + }); + } +}); + +/** + * GET /api/commits/repository/:repositoryId/stats + * Get repository commit statistics + */ +router.get('/repository/:repositoryId/stats', async (req, res) => { + try { + const { repositoryId } = req.params; + + const stats = await commitTrackingService.getRepositoryStats(repositoryId); + + res.json({ + success: true, + data: stats + }); + } catch (error) { + console.error('Error getting repository stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get repository stats', + message: error.message + }); + } +}); + +/** + * POST /api/commits/process-webhook + * Process commits from webhook payload + */ +router.post('/process-webhook', async (req, res) => { + try { + const { webhookPayload, repositoryId } = req.body; + + if (!webhookPayload || !repositoryId) { + return res.status(400).json({ + success: false, + error: 'webhookPayload and repositoryId are required' + }); + } + + const processedCommits = await commitTrackingService.processWebhookCommits( + webhookPayload, + repositoryId + ); + + res.json({ + success: true, + data: { + processed_commits: processedCommits.length, + commits: processedCommits + } + }); + } catch (error) { + console.error('Error processing webhook commits:', error); + res.status(500).json({ + success: false, + error: 'Failed to process webhook commits', + message: error.message + }); + } +}); + +/** + * GET /api/commits/:commitId/diffs + * Get diff information for a commit + */ +router.get('/:commitId/diffs', async (req, res) => { + try { + const { commitId } = req.params; + + const diffs = await diffStorageModel.getDiffsByCommit(commitId); + + res.json({ + success: true, + data: diffs + }); + } catch (error) { + console.error('Error getting commit diffs:', error); + res.status(500).json({ + success: false, + error: 'Failed to get commit diffs', + message: error.message + }); + } +}); + +/** + * GET /api/commits/diffs/:diffId/content + * Get actual diff content + */ +router.get('/diffs/:diffId/content', async (req, res) => { + try { + const { diffId } = req.params; + + const diffContent = await diffProcessingService.retrieveDiffContent(diffId); + + res.set('Content-Type', 'text/plain'); + res.send(diffContent); + } catch (error) { + console.error('Error getting diff content:', error); + res.status(500).json({ + success: false, + error: 'Failed to get diff content', + message: error.message + }); + } +}); + +/** + * GET /api/commits/repository/:repositoryId/diff-stats + * Get diff statistics for a repository + */ +router.get('/repository/:repositoryId/diff-stats', async (req, res) => { + try { + const { repositoryId } = req.params; + const { days_back = 30 } = req.query; + + const stats = await diffStorageModel.getRepositoryDiffStats( + repositoryId, + parseInt(days_back) + ); + + res.json({ + success: true, + data: stats + }); + } catch (error) { + console.error('Error getting diff stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get diff stats', + message: error.message + }); + } +}); + +/** + * POST /api/commits/process-diff-queue + * Manually trigger diff processing + */ +router.post('/process-diff-queue', async (req, res) => { + try { + const result = await diffProcessingService.processNextQueueItem(); + + if (result) { + res.json({ + success: true, + data: result, + message: 'Diff processing completed' + }); + } else { + res.json({ + success: true, + data: null, + message: 'No items in queue to process' + }); + } + } catch (error) { + console.error('Error processing diff queue:', error); + res.status(500).json({ + success: false, + error: 'Failed to process diff queue', + message: error.message + }); + } +}); + +module.exports = router; diff --git a/services/git-integration/src/routes/enhanced-webhooks.routes.js b/services/git-integration/src/routes/enhanced-webhooks.routes.js new file mode 100644 index 0000000..b0c34cd --- /dev/null +++ b/services/git-integration/src/routes/enhanced-webhooks.routes.js @@ -0,0 +1,369 @@ +const express = require('express'); +const router = express.Router(); +const EnhancedWebhookService = require('../services/enhanced-webhook.service'); + +const webhookService = new EnhancedWebhookService(); + +/** + * POST /api/webhooks/github + * Handle GitHub webhooks + */ +router.post('/github', async (req, res) => { + try { + const payload = req.body; + const headers = req.headers; + + console.log(`Received GitHub webhook: ${headers['x-github-event']}`); + + const result = await webhookService.processGitHubWebhook(payload, headers); + + res.json({ + success: true, + data: result, + message: 'GitHub webhook processed successfully' + }); + } catch (error) { + console.error('Error processing GitHub webhook:', error); + res.status(500).json({ + success: false, + error: 'Failed to process GitHub webhook', + message: error.message + }); + } +}); + +/** + * POST /api/webhooks/gitlab + * Handle GitLab webhooks + */ +router.post('/gitlab', async (req, res) => { + try { + const payload = req.body; + const headers = req.headers; + + console.log(`Received GitLab webhook: ${headers['x-gitlab-event']}`); + + const result = await webhookService.processGitLabWebhook(payload, headers); + + res.json({ + success: true, + data: result, + message: 'GitLab webhook processed successfully' + }); + } catch (error) { + console.error('Error processing GitLab webhook:', error); + res.status(500).json({ + success: false, + error: 'Failed to process GitLab webhook', + message: error.message + }); + } +}); + +/** + * POST /api/webhooks/bitbucket + * Handle Bitbucket webhooks + */ +router.post('/bitbucket', async (req, res) => { + try { + const payload = req.body; + const headers = req.headers; + + console.log(`Received Bitbucket webhook: ${headers['x-event-key']}`); + + const result = await webhookService.processBitbucketWebhook(payload, headers); + + res.json({ + success: true, + data: result, + message: 'Bitbucket webhook processed successfully' + }); + } catch (error) { + console.error('Error processing Bitbucket webhook:', error); + res.status(500).json({ + success: false, + error: 'Failed to process Bitbucket webhook', + message: error.message + }); + } +}); + +/** + * POST /api/webhooks/gitea + * Handle Gitea webhooks + */ +router.post('/gitea', async (req, res) => { + try { + const payload = req.body; + const headers = req.headers; + + console.log(`Received Gitea webhook: ${headers['x-gitea-event']}`); + + const result = await webhookService.processGiteaWebhook(payload, headers); + + res.json({ + success: true, + data: result, + message: 'Gitea webhook processed successfully' + }); + } catch (error) { + console.error('Error processing Gitea webhook:', error); + res.status(500).json({ + success: false, + error: 'Failed to process Gitea webhook', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/github/recent + * Get recent GitHub webhooks + */ +router.get('/github/recent', async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query; + + const query = ` + SELECT id, delivery_id, event_type, action, owner_name, repository_name, + ref, commit_count, processed_at, created_at + FROM github_webhooks + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + + const result = await webhookService.db.query(query, [parseInt(limit), parseInt(offset)]); + + res.json({ + success: true, + data: result.rows, + pagination: { + limit: parseInt(limit), + offset: parseInt(offset), + total: result.rows.length + } + }); + } catch (error) { + console.error('Error getting recent GitHub webhooks:', error); + res.status(500).json({ + success: false, + error: 'Failed to get recent GitHub webhooks', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/gitlab/recent + * Get recent GitLab webhooks + */ +router.get('/gitlab/recent', async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query; + + const query = ` + SELECT id, delivery_id, event_type, action, owner_name, repository_name, + ref, commit_count, created_at + FROM gitlab_webhooks + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + + const result = await webhookService.db.query(query, [parseInt(limit), parseInt(offset)]); + + res.json({ + success: true, + data: result.rows, + pagination: { + limit: parseInt(limit), + offset: parseInt(offset), + total: result.rows.length + } + }); + } catch (error) { + console.error('Error getting recent GitLab webhooks:', error); + res.status(500).json({ + success: false, + error: 'Failed to get recent GitLab webhooks', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/bitbucket/recent + * Get recent Bitbucket webhooks + */ +router.get('/bitbucket/recent', async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query; + + const query = ` + SELECT id, delivery_id, event_type, action, owner_name, repository_name, + ref, commit_count, created_at + FROM bitbucket_webhooks + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + + const result = await webhookService.db.query(query, [parseInt(limit), parseInt(offset)]); + + res.json({ + success: true, + data: result.rows, + pagination: { + limit: parseInt(limit), + offset: parseInt(offset), + total: result.rows.length + } + }); + } catch (error) { + console.error('Error getting recent Bitbucket webhooks:', error); + res.status(500).json({ + success: false, + error: 'Failed to get recent Bitbucket webhooks', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/gitea/recent + * Get recent Gitea webhooks + */ +router.get('/gitea/recent', async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query; + + const query = ` + SELECT id, delivery_id, event_type, action, owner_name, repository_name, + ref, commit_count, created_at + FROM gitea_webhooks + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + + const result = await webhookService.db.query(query, [parseInt(limit), parseInt(offset)]); + + res.json({ + success: true, + data: result.rows, + pagination: { + limit: parseInt(limit), + offset: parseInt(offset), + total: result.rows.length + } + }); + } catch (error) { + console.error('Error getting recent Gitea webhooks:', error); + res.status(500).json({ + success: false, + error: 'Failed to get recent Gitea webhooks', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/stats + * Get webhook statistics across all providers + */ +router.get('/stats', async (req, res) => { + try { + const { days_back = 7 } = req.query; + + const queries = [ + `SELECT 'github' as provider, COUNT(*) as total, + COUNT(*) FILTER (WHERE processed_at IS NOT NULL) as processed + FROM github_webhooks + WHERE created_at >= NOW() - INTERVAL '${parseInt(days_back)} days'`, + + `SELECT 'gitlab' as provider, COUNT(*) as total, + COUNT(*) as processed + FROM gitlab_webhooks + WHERE created_at >= NOW() - INTERVAL '${parseInt(days_back)} days'`, + + `SELECT 'bitbucket' as provider, COUNT(*) as total, + COUNT(*) as processed + FROM bitbucket_webhooks + WHERE created_at >= NOW() - INTERVAL '${parseInt(days_back)} days'`, + + `SELECT 'gitea' as provider, COUNT(*) as total, + COUNT(*) as processed + FROM gitea_webhooks + WHERE created_at >= NOW() - INTERVAL '${parseInt(days_back)} days'` + ]; + + const results = await Promise.all( + queries.map(query => webhookService.db.query(query)) + ); + + const stats = results.map(result => result.rows[0]).filter(row => row.total > 0); + + res.json({ + success: true, + data: { + period_days: parseInt(days_back), + providers: stats, + total_webhooks: stats.reduce((sum, stat) => sum + parseInt(stat.total), 0), + total_processed: stats.reduce((sum, stat) => sum + parseInt(stat.processed), 0) + } + }); + } catch (error) { + console.error('Error getting webhook stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get webhook stats', + message: error.message + }); + } +}); + +/** + * GET /api/webhooks/:provider/:webhookId/payload + * Get full webhook payload by ID + */ +router.get('/:provider/:webhookId/payload', async (req, res) => { + try { + const { provider, webhookId } = req.params; + + const tableMap = { + 'github': 'github_webhooks', + 'gitlab': 'gitlab_webhooks', + 'bitbucket': 'bitbucket_webhooks', + 'gitea': 'gitea_webhooks' + }; + + const tableName = tableMap[provider]; + if (!tableName) { + return res.status(400).json({ + success: false, + error: 'Invalid provider. Must be github, gitlab, bitbucket, or gitea' + }); + } + + const query = `SELECT payload FROM ${tableName} WHERE id = $1`; + const result = await webhookService.db.query(query, [webhookId]); + + if (result.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'Webhook not found' + }); + } + + res.json({ + success: true, + data: result.rows[0].payload + }); + } catch (error) { + console.error('Error getting webhook payload:', error); + res.status(500).json({ + success: false, + error: 'Failed to get webhook payload', + message: error.message + }); + } +}); + +module.exports = router; diff --git a/services/git-integration/src/routes/github-integration.routes.js b/services/git-integration/src/routes/github-integration.routes.js index 99230d4..bbc38b6 100644 --- a/services/git-integration/src/routes/github-integration.routes.js +++ b/services/git-integration/src/routes/github-integration.routes.js @@ -1168,11 +1168,7 @@ router.delete('/repository/:id', async (req, res) => { // Clean up file storage await githubService.cleanupRepositoryStorage(id); - // Delete feature mappings first - await database.query( - 'DELETE FROM feature_codebase_mappings WHERE repository_id = $1', - [id] - ); + // Delete repository record await database.query( diff --git a/services/git-integration/src/routes/oauth-providers.routes.js b/services/git-integration/src/routes/oauth-providers.routes.js new file mode 100644 index 0000000..9f07a36 --- /dev/null +++ b/services/git-integration/src/routes/oauth-providers.routes.js @@ -0,0 +1,384 @@ +const express = require('express'); +const router = express.Router(); +const OAuthTokensModel = require('../models/oauth-tokens.model'); + +const oauthTokensModel = new OAuthTokensModel(); + +/** + * POST /api/oauth/gitlab/token + * Store GitLab OAuth token + */ +router.post('/gitlab/token', async (req, res) => { + try { + const { + access_token, + gitlab_username, + gitlab_user_id, + scopes, + expires_at + } = req.body; + + if (!access_token || !gitlab_username) { + return res.status(400).json({ + success: false, + error: 'access_token and gitlab_username are required' + }); + } + + const tokenData = { + access_token, + gitlab_username, + gitlab_user_id, + scopes, + expires_at: expires_at ? new Date(expires_at) : null + }; + + const token = await oauthTokensModel.createGitLabToken(tokenData); + + res.json({ + success: true, + data: { + id: token.id, + gitlab_username: token.gitlab_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error storing GitLab token:', error); + res.status(500).json({ + success: false, + error: 'Failed to store GitLab token', + message: error.message + }); + } +}); + +/** + * GET /api/oauth/gitlab/token/:username + * Get GitLab OAuth token by username + */ +router.get('/gitlab/token/:username', async (req, res) => { + try { + const { username } = req.params; + + const token = await oauthTokensModel.getGitLabTokenByUsername(username); + + if (!token) { + return res.status(404).json({ + success: false, + error: 'GitLab token not found' + }); + } + + res.json({ + success: true, + data: { + id: token.id, + gitlab_username: token.gitlab_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error getting GitLab token:', error); + res.status(500).json({ + success: false, + error: 'Failed to get GitLab token', + message: error.message + }); + } +}); + +/** + * POST /api/oauth/bitbucket/token + * Store Bitbucket OAuth token + */ +router.post('/bitbucket/token', async (req, res) => { + try { + const { + access_token, + bitbucket_username, + bitbucket_user_id, + scopes, + expires_at + } = req.body; + + if (!access_token || !bitbucket_username) { + return res.status(400).json({ + success: false, + error: 'access_token and bitbucket_username are required' + }); + } + + const tokenData = { + access_token, + bitbucket_username, + bitbucket_user_id, + scopes, + expires_at: expires_at ? new Date(expires_at) : null + }; + + const token = await oauthTokensModel.createBitbucketToken(tokenData); + + res.json({ + success: true, + data: { + id: token.id, + bitbucket_username: token.bitbucket_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error storing Bitbucket token:', error); + res.status(500).json({ + success: false, + error: 'Failed to store Bitbucket token', + message: error.message + }); + } +}); + +/** + * GET /api/oauth/bitbucket/token/:username + * Get Bitbucket OAuth token by username + */ +router.get('/bitbucket/token/:username', async (req, res) => { + try { + const { username } = req.params; + + const token = await oauthTokensModel.getBitbucketTokenByUsername(username); + + if (!token) { + return res.status(404).json({ + success: false, + error: 'Bitbucket token not found' + }); + } + + res.json({ + success: true, + data: { + id: token.id, + bitbucket_username: token.bitbucket_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error getting Bitbucket token:', error); + res.status(500).json({ + success: false, + error: 'Failed to get Bitbucket token', + message: error.message + }); + } +}); + +/** + * POST /api/oauth/gitea/token + * Store Gitea OAuth token + */ +router.post('/gitea/token', async (req, res) => { + try { + const { + access_token, + gitea_username, + gitea_user_id, + scopes, + expires_at + } = req.body; + + if (!access_token || !gitea_username) { + return res.status(400).json({ + success: false, + error: 'access_token and gitea_username are required' + }); + } + + const tokenData = { + access_token, + gitea_username, + gitea_user_id, + scopes, + expires_at: expires_at ? new Date(expires_at) : null + }; + + const token = await oauthTokensModel.createGiteaToken(tokenData); + + res.json({ + success: true, + data: { + id: token.id, + gitea_username: token.gitea_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error storing Gitea token:', error); + res.status(500).json({ + success: false, + error: 'Failed to store Gitea token', + message: error.message + }); + } +}); + +/** + * GET /api/oauth/gitea/token/:username + * Get Gitea OAuth token by username + */ +router.get('/gitea/token/:username', async (req, res) => { + try { + const { username } = req.params; + + const token = await oauthTokensModel.getGiteaTokenByUsername(username); + + if (!token) { + return res.status(404).json({ + success: false, + error: 'Gitea token not found' + }); + } + + res.json({ + success: true, + data: { + id: token.id, + gitea_username: token.gitea_username, + scopes: token.scopes, + expires_at: token.expires_at, + created_at: token.created_at + } + }); + } catch (error) { + console.error('Error getting Gitea token:', error); + res.status(500).json({ + success: false, + error: 'Failed to get Gitea token', + message: error.message + }); + } +}); + +/** + * GET /api/oauth/:provider/tokens + * Get all tokens for a provider + */ +router.get('/:provider/tokens', async (req, res) => { + try { + const { provider } = req.params; + + if (!['gitlab', 'bitbucket', 'gitea'].includes(provider)) { + return res.status(400).json({ + success: false, + error: 'Invalid provider. Must be gitlab, bitbucket, or gitea' + }); + } + + const tokens = await oauthTokensModel.getAllTokensByProvider(provider); + + // Remove sensitive access_token from response + const sanitizedTokens = tokens.map(token => { + const { access_token, ...safeToken } = token; + return safeToken; + }); + + res.json({ + success: true, + data: sanitizedTokens + }); + } catch (error) { + console.error('Error getting provider tokens:', error); + res.status(500).json({ + success: false, + error: 'Failed to get provider tokens', + message: error.message + }); + } +}); + +/** + * DELETE /api/oauth/expired-tokens + * Clean up expired tokens + */ +router.delete('/expired-tokens', async (req, res) => { + try { + const deletedCount = await oauthTokensModel.deleteExpiredTokens(); + + res.json({ + success: true, + data: { + deleted_count: deletedCount + }, + message: `Deleted ${deletedCount} expired tokens` + }); + } catch (error) { + console.error('Error deleting expired tokens:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete expired tokens', + message: error.message + }); + } +}); + +/** + * PUT /api/oauth/gitlab/token/:tokenId + * Update GitLab OAuth token + */ +router.put('/gitlab/token/:tokenId', async (req, res) => { + try { + const { tokenId } = req.params; + const { access_token, scopes, expires_at } = req.body; + + if (!access_token) { + return res.status(400).json({ + success: false, + error: 'access_token is required' + }); + } + + const tokenData = { + access_token, + scopes, + expires_at: expires_at ? new Date(expires_at) : null + }; + + const updatedToken = await oauthTokensModel.updateGitLabToken(tokenId, tokenData); + + if (!updatedToken) { + return res.status(404).json({ + success: false, + error: 'Token not found' + }); + } + + res.json({ + success: true, + data: { + id: updatedToken.id, + gitlab_username: updatedToken.gitlab_username, + scopes: updatedToken.scopes, + expires_at: updatedToken.expires_at, + updated_at: updatedToken.updated_at + } + }); + } catch (error) { + console.error('Error updating GitLab token:', error); + res.status(500).json({ + success: false, + error: 'Failed to update GitLab token', + message: error.message + }); + } +}); + +module.exports = router; diff --git a/services/git-integration/src/routes/vcs.routes.js b/services/git-integration/src/routes/vcs.routes.js index 51e5fae..d8926d1 100644 --- a/services/git-integration/src/routes/vcs.routes.js +++ b/services/git-integration/src/routes/vcs.routes.js @@ -156,10 +156,7 @@ router.post('/:provider/attach-repository', async (req, res) => { mappingValues.push(`(uuid_generate_v4(), $${i++}, $${i++}, $${i++}, $${i++})`); params.push(feature.id, repositoryRecord.id, '[]', '{}'); } - await database.query( - `INSERT INTO feature_codebase_mappings (id, feature_id, repository_id, code_paths, code_snippets) VALUES ${mappingValues.join(', ')}`, - params - ); + } const storageInfo = await (async () => { @@ -209,7 +206,7 @@ router.post('/:provider/webhook', async (req, res) => { } // Signature verification - const rawBody = JSON.stringify(payload); + const rawBody = req.rawBody ? req.rawBody : Buffer.from(JSON.stringify(payload)); const verifySignature = () => { try { if (providerKey === 'gitlab') { @@ -220,12 +217,19 @@ router.post('/:provider/webhook', async (req, res) => { } if (providerKey === 'gitea') { const crypto = require('crypto'); - const provided = req.headers['x-gitea-signature']; + const providedHeader = req.headers['x-gitea-signature'] || req.headers['x-gogs-signature'] || req.headers['x-hub-signature-256']; const secret = process.env.GITEA_WEBHOOK_SECRET; if (!secret) return true; - if (!provided) return false; + if (!providedHeader) return false; + let provided = String(providedHeader); + if (provided.startsWith('sha256=')) provided = provided.slice('sha256='.length); const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); - return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex')); + + try { + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex')); + } catch (_) { + return false; + } } if (providerKey === 'bitbucket') { // Bitbucket Cloud webhooks typically have no shared secret by default @@ -241,6 +245,14 @@ router.post('/:provider/webhook', async (req, res) => { return res.status(401).json({ success: false, message: 'Invalid webhook signature' }); } + if (providerKey === 'bitbucket') { + console.log('๐Ÿ”” Bitbucket webhook received:', { + eventKey: req.headers['x-event-key'], + requestId: req.headers['x-request-id'], + userAgent: req.headers['user-agent'], + payloadSize: rawBody?.length || 0 + }); + } // Process webhook event using comprehensive service const eventType = extractEventType(providerKey, payload); await vcsWebhookService.processWebhookEvent(providerKey, eventType, payload); @@ -378,10 +390,8 @@ router.get('/:provider/repository/:id/file-content', async (req, res) => { return res.status(400).json({ success: false, message: 'File path is required' }); } const query = ` - SELECT rf.*, rfc.content_text, rfc.content_preview, rfc.language_detected, - rfc.line_count, rfc.char_count + SELECT rf.*t FROM repository_files rf - LEFT JOIN repository_file_contents rfc ON rf.id = rfc.file_id WHERE rf.repository_id = $1 AND rf.relative_path = $2 `; const result = await database.query(query, [id, file_path]); @@ -449,7 +459,6 @@ router.delete('/:provider/repository/:id', async (req, res) => { } const repository = getResult.rows[0]; await fileStorageService.cleanupRepositoryStorage(id); - await database.query('DELETE FROM feature_codebase_mappings WHERE repository_id = $1', [id]); await database.query('DELETE FROM github_repositories WHERE id = $1', [id]); res.json({ success: true, message: 'Repository removed successfully', data: { removed_repository: repository.repository_name, template_id: repository.template_id } }); } catch (error) { @@ -459,7 +468,7 @@ router.delete('/:provider/repository/:id', async (req, res) => { }); // OAuth placeholders (start/callback) per provider for future implementation -router.get('/:provider/auth/start', (req, res) => { +router.get('/:provider/auth/start', async (req, res) => { try { const providerKey = (req.params.provider || '').toLowerCase(); const oauth = getOAuthService(providerKey); @@ -479,14 +488,29 @@ router.get('/:provider/auth/callback', (req, res) => { const code = req.query.code; const error = req.query.error; const errorDescription = req.query.error_description; + console.log(`๐Ÿ”„ [VCS OAUTH] Callback received for ${providerKey}:`, { + hasCode: !!code, + hasError: !!error, + code: code?.substring(0, 10) + '...', + error, + errorDescription + }); const oauth = getOAuthService(providerKey); if (!oauth) return res.status(400).json({ success: false, message: 'Unsupported provider or OAuth not available' }); if (!code) { // Surface upstream provider error details if present if (error || errorDescription) { - return res.status(400).json({ success: false, message: 'OAuth error from provider', provider: providerKey, error: error || 'unknown_error', error_description: errorDescription || null, query: req.query }); + console.error(`โŒ [VCS OAUTH] Provider error for ${providerKey}:`, { error, errorDescription }); + return res.status(400).json({ + success: false, + message: 'OAuth error from provider', + provider: providerKey, + error: error || 'unknown_error', + error_description: errorDescription || null, + query: req.query + }); } - return res.status(400).json({ success: false, message: 'Missing code' }); + return res.status(400).json({ success: false, message: 'Missing authorization code' }); } const accessToken = await oauth.exchangeCodeForToken(code); const user = await oauth.getUserInfo(accessToken); @@ -504,7 +528,34 @@ router.get('/:provider/auth/callback', (req, res) => { const tokenRecord = await oauth.storeToken(accessToken, user, userId || null); res.json({ success: true, provider: providerKey, user, token: { id: tokenRecord.id || null } }); } catch (e) { - res.status(500).json({ success: false, message: e.message || 'OAuth callback failed' }); + + console.error(`โŒ [VCS OAUTH] Callback error for ${req.params.provider}:`, e); + + // Provide more specific error messages + let errorMessage = e.message || 'OAuth callback failed'; + let statusCode = 500; + + if (e.message.includes('not configured')) { + statusCode = 500; + errorMessage = `OAuth configuration error: ${e.message}`; + } else if (e.message.includes('timeout')) { + statusCode = 504; + errorMessage = `OAuth timeout: ${e.message}`; + } else if (e.message.includes('network error') || e.message.includes('Cannot connect')) { + statusCode = 502; + errorMessage = `Network error: ${e.message}`; + } else if (e.message.includes('HTTP error')) { + statusCode = 502; + errorMessage = `OAuth provider error: ${e.message}`; + } + + res.status(statusCode).json({ + success: false, + message: errorMessage, + provider: req.params.provider, + error: e.message, + details: process.env.NODE_ENV === 'development' ? e.stack : undefined + }); } })(); }); diff --git a/services/git-integration/src/routes/webhook.routes.js b/services/git-integration/src/routes/webhook.routes.js index 884e222..e03ceb2 100644 --- a/services/git-integration/src/routes/webhook.routes.js +++ b/services/git-integration/src/routes/webhook.routes.js @@ -45,21 +45,7 @@ router.post('/webhook', async (req, res) => { await webhookService.processWebhookEvent(eventType, payloadWithDelivery); } - // Log the webhook event - await webhookService.logWebhookEvent( - eventType || 'unknown', - req.body.action || 'unknown', - req.body.repository?.full_name || 'unknown', - { - delivery_id: deliveryId, - event_type: eventType, - action: req.body.action, - repository: req.body.repository?.full_name, - sender: req.body.sender?.login - }, - deliveryId, - payloadWithDelivery - ); + res.status(200).json({ success: true, diff --git a/services/git-integration/src/services/bitbucket-oauth.js b/services/git-integration/src/services/bitbucket-oauth.js index 59fe82e..579bc9a 100644 --- a/services/git-integration/src/services/bitbucket-oauth.js +++ b/services/git-integration/src/services/bitbucket-oauth.js @@ -10,12 +10,13 @@ class BitbucketOAuthService { getAuthUrl(state) { if (!this.clientId) throw new Error('Bitbucket OAuth not configured'); + const scopes = process.env.BITBUCKET_OAUTH_SCOPES || 'repository account'; const params = new URLSearchParams({ client_id: this.clientId, response_type: 'code', state, // Bitbucket Cloud uses 'repository' for read access; 'repository:write' for write - scope: 'repository account', + scope: scopes, redirect_uri: this.redirectUri }); return `https://bitbucket.org/site/oauth2/authorize?${params.toString()}`; @@ -48,7 +49,7 @@ class BitbucketOAuthService { VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET access_token = EXCLUDED.access_token, bitbucket_username = EXCLUDED.bitbucket_username, bitbucket_user_id = EXCLUDED.bitbucket_user_id, scopes = EXCLUDED.scopes, expires_at = EXCLUDED.expires_at, updated_at = NOW() RETURNING *`, - [accessToken, user.username || user.display_name, user.uuid || null, JSON.stringify(['repository:read','account']), null] + [accessToken, user.username || user.display_name, user.uuid || null, JSON.stringify(['repository:admin','webhook','account']), null] ); return result.rows[0]; } diff --git a/services/git-integration/src/services/commit-tracking.service.js b/services/git-integration/src/services/commit-tracking.service.js new file mode 100644 index 0000000..18b3fef --- /dev/null +++ b/services/git-integration/src/services/commit-tracking.service.js @@ -0,0 +1,239 @@ +const CommitDetailsModel = require('../models/commit-details.model'); +const CommitFilesModel = require('../models/commit-files.model'); +const DiffStorageModel = require('../models/diff-storage.model'); + +class CommitTrackingService { + constructor() { + this.commitDetailsModel = new CommitDetailsModel(); + this.commitFilesModel = new CommitFilesModel(); + this.diffStorageModel = new DiffStorageModel(); + } + + /** + * Process a complete commit with its file changes + * @param {Object} commitData - Commit information + * @param {Array} fileChanges - Array of file changes + * @returns {Object} Processed commit with files + */ + async processCommitWithFiles(commitData, fileChanges = []) { + try { + // Create commit detail record + const commit = await this.commitDetailsModel.createCommitDetail(commitData); + + // Create file change records if provided + let files = []; + if (fileChanges && fileChanges.length > 0) { + files = await this.commitFilesModel.createMultipleCommitFiles(commit.id, fileChanges); + + // Queue diff processing for this commit + await this.queueDiffProcessing(commit, commitData.repository_id); + } + + return { + commit, + files, + total_files: files.length + }; + } catch (error) { + console.error('Error processing commit with files:', error); + throw error; + } + } + + /** + * Queue diff processing for a commit + * @param {Object} commit - Commit details + * @param {string} repository_id - Repository ID + */ + async queueDiffProcessing(commit, repository_id) { + try { + const queueData = { + commit_id: commit.id, + repository_id: repository_id, + to_sha: commit.commit_sha, + repo_local_path: `/tmp/repos/${repository_id}`, // This should be configurable + priority: 1 + }; + + await this.diffStorageModel.addToProcessingQueue(queueData); + console.log(`Queued diff processing for commit ${commit.commit_sha}`); + } catch (error) { + console.error('Error queuing diff processing:', error); + // Don't throw - this is not critical for commit processing + } + } + + /** + * Get commit history for a repository with file changes + * @param {string} repository_id - Repository ID + * @param {Object} options - Query options + * @returns {Array} Commit history with file counts + */ + async getCommitHistory(repository_id, options = {}) { + const { limit = 50, offset = 0, include_files = false } = options; + + try { + const commits = await this.commitDetailsModel.getCommitsByRepository( + repository_id, + limit, + offset + ); + + if (include_files) { + // Fetch file changes for each commit + for (const commit of commits) { + const files = await this.commitFilesModel.getFilesByCommit(commit.id); + commit.files = files; + commit.file_count = files.length; + } + } else { + // Just get file counts + for (const commit of commits) { + const files = await this.commitFilesModel.getFilesByCommit(commit.id); + commit.file_count = files.length; + } + } + + return commits; + } catch (error) { + console.error('Error getting commit history:', error); + throw error; + } + } + + /** + * Get file change history for a specific file path + * @param {string} repository_id - Repository ID + * @param {string} file_path - File path + * @returns {Array} File change history + */ + async getFileHistory(repository_id, file_path) { + try { + return await this.commitFilesModel.getFileChangesByPath(repository_id, file_path); + } catch (error) { + console.error('Error getting file history:', error); + throw error; + } + } + + /** + * Process webhook commit data + * @param {Object} webhookPayload - Webhook payload + * @param {string} repository_id - Repository ID + * @returns {Array} Processed commits + */ + async processWebhookCommits(webhookPayload, repository_id) { + try { + const commits = webhookPayload.commits || []; + const processedCommits = []; + + for (const commitData of commits) { + const processedCommit = await this.processCommitFromWebhook(commitData, repository_id); + processedCommits.push(processedCommit); + } + + return processedCommits; + } catch (error) { + console.error('Error processing webhook commits:', error); + throw error; + } + } + + /** + * Process a single commit from webhook data + * @param {Object} commitData - Commit data from webhook + * @param {string} repository_id - Repository ID + * @returns {Object} Processed commit + */ + async processCommitFromWebhook(commitData, repository_id) { + try { + const commit = { + repository_id, + commit_sha: commitData.id, + author_name: commitData.author?.name, + author_email: commitData.author?.email, + message: commitData.message, + url: commitData.url, + committed_at: new Date(commitData.timestamp) + }; + + // Extract file changes + const fileChanges = []; + + // Added files + if (commitData.added) { + commitData.added.forEach(filePath => { + fileChanges.push({ + change_type: 'added', + file_path: filePath + }); + }); + } + + // Modified files + if (commitData.modified) { + commitData.modified.forEach(filePath => { + fileChanges.push({ + change_type: 'modified', + file_path: filePath + }); + }); + } + + // Removed files + if (commitData.removed) { + commitData.removed.forEach(filePath => { + fileChanges.push({ + change_type: 'removed', + file_path: filePath + }); + }); + } + + return await this.processCommitWithFiles(commit, fileChanges); + } catch (error) { + console.error('Error processing commit from webhook:', error); + throw error; + } + } + + /** + * Get repository statistics + * @param {string} repository_id - Repository ID + * @returns {Object} Repository statistics + */ + async getRepositoryStats(repository_id) { + try { + const commits = await this.commitDetailsModel.getCommitsByRepository(repository_id, 1000); + const recentFiles = await this.commitFilesModel.getFilesByRepository(repository_id, 100); + + // Calculate statistics + const totalCommits = commits.length; + const uniqueAuthors = new Set(commits.map(c => c.author_email)).size; + const recentActivity = commits.filter(c => { + const commitDate = new Date(c.committed_at); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + return commitDate > thirtyDaysAgo; + }).length; + + // File change statistics + const fileStats = recentFiles.reduce((acc, file) => { + acc[file.change_type] = (acc[file.change_type] || 0) + 1; + return acc; + }, {}); + + return { + total_commits: totalCommits, + unique_authors: uniqueAuthors, + recent_activity_30_days: recentActivity, + file_changes: fileStats, + last_commit: commits[0] || null + }; + } catch (error) { + console.error('Error getting repository stats:', error); + throw error; + } + } +} + +module.exports = CommitTrackingService; diff --git a/services/git-integration/src/services/diff-processing.service.js b/services/git-integration/src/services/diff-processing.service.js new file mode 100644 index 0000000..fcc5f7a --- /dev/null +++ b/services/git-integration/src/services/diff-processing.service.js @@ -0,0 +1,421 @@ +// services/diff-processing.service.js +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const database = require('../config/database'); +const GitRepoService = require('./git-repo.service'); + +class DiffProcessingService { + constructor() { + this.gitService = new GitRepoService(); + this.baseDir = process.env.ATTACHED_REPOS_DIR || '/tmp/attached-repos'; + // Allow overriding diff storage root via env; fallback to /diffs + const envDiffDir = process.env.DIFF_STORAGE_DIR && process.env.DIFF_STORAGE_DIR.trim().length > 0 + ? process.env.DIFF_STORAGE_DIR + : null; + this.diffStorageDir = envDiffDir || path.join(this.baseDir, 'diffs'); + + // Size threshold: <= 50KB store in DB; > 50KB store on disk + this.SIZE_THRESHOLDS = { + SMALL: 50 * 1024 + }; + + this.ensureDiffStorageDir(); + } + + ensureDiffStorageDir() { + if (!fs.existsSync(this.diffStorageDir)) { + fs.mkdirSync(this.diffStorageDir, { recursive: true }); + } + } + + // Main method to process diffs for a commit + async processCommitDiffs(commitId, repositoryId, repoPath, fromSha, toSha) { + console.log(`๐Ÿ”„ Processing diffs for commit ${commitId}`); + + try { + // Get the diff content + const diffContent = await this.gitService.getDiff(repoPath, fromSha, toSha); + + if (!diffContent || diffContent.trim().length === 0) { + console.log(`โš ๏ธ No diff content found for commit ${commitId}`); + return { success: false, reason: 'No diff content' }; + } + + // Parse the diff to extract individual file changes + const fileDiffs = this.parseDiffContent(diffContent); + + console.log(`๐Ÿ“„ Found ${fileDiffs.length} file changes in diff`); + + // Process each file diff + const results = []; + for (const fileDiff of fileDiffs) { + try { + const result = await this.processFileDiff(commitId, fileDiff); + results.push(result); + } catch (error) { + console.error(`โŒ Failed to process diff for file ${fileDiff.filePath}:`, error.message); + results.push({ + success: false, + filePath: fileDiff.filePath, + error: error.message + }); + } + } + + // Update processing statistics + await this.updateDiffStatistics(repositoryId, results); + + return { + success: true, + processedFiles: results.length, + results: results + }; + + } catch (error) { + console.error(`โŒ Failed to process commit diffs:`, error.message); + return { success: false, error: error.message }; + } + } + + // Parse diff content into individual file diffs + parseDiffContent(diffContent) { + const fileDiffs = []; + const lines = diffContent.split('\n'); + + let currentFileDiff = null; + let currentDiffBody = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for file header (diff --git) + if (line.startsWith('diff --git')) { + // Save previous file diff if exists + if (currentFileDiff) { + currentFileDiff.diffBody = currentDiffBody.join('\n'); + // Infer accurate change type and canonical paths from diff body + this.inferChangeTypeAndPaths(currentFileDiff); + fileDiffs.push(currentFileDiff); + } + + // Start new file diff + currentFileDiff = this.parseFileHeader(line); + currentDiffBody = [line]; + } else if (currentFileDiff) { + currentDiffBody.push(line); + } + } + + // Don't forget the last file diff + if (currentFileDiff) { + currentFileDiff.diffBody = currentDiffBody.join('\n'); + this.inferChangeTypeAndPaths(currentFileDiff); + fileDiffs.push(currentFileDiff); + } + + return fileDiffs; + } + + // Parse file header from diff line + parseFileHeader(diffLine) { + // Example: diff --git a/src/app.js b/src/app.js + const match = diffLine.match(/diff --git a\/(.+) b\/(.+)/); + if (!match) { + throw new Error(`Invalid diff header: ${diffLine}`); + } + + const oldPath = match[1]; + const newPath = match[2]; + + // Determine change type + let changeType = 'modified'; + if (oldPath === '/dev/null') { + changeType = 'added'; + } else if (newPath === '/dev/null') { + changeType = 'deleted'; + } else if (oldPath !== newPath) { + changeType = 'renamed'; + } + + return { + filePath: newPath === '/dev/null' ? oldPath : newPath, + oldPath: oldPath === '/dev/null' ? null : oldPath, + newPath: newPath === '/dev/null' ? null : newPath, + changeType: changeType, + diffHeader: diffLine + }; + } + + // Inspect the diff body to accurately infer change type and canonical paths + inferChangeTypeAndPaths(fileDiff) { + if (!fileDiff || !fileDiff.diffBody) return; + + const lines = fileDiff.diffBody.split('\n'); + let headerOld = null; + let headerNew = null; + let sawNewFile = false; + let sawDeletedFile = false; + let sawRenameFrom = null; + let sawRenameTo = null; + + for (const l of lines) { + if (l.startsWith('new file mode')) sawNewFile = true; + if (l.startsWith('deleted file mode')) sawDeletedFile = true; + if (l.startsWith('rename from ')) sawRenameFrom = l.replace('rename from ', '').trim(); + if (l.startsWith('rename to ')) sawRenameTo = l.replace('rename to ', '').trim(); + if (l.startsWith('--- ')) headerOld = l.substring(4).trim(); // e.g., 'a/path' or '/dev/null' + if (l.startsWith('+++ ')) headerNew = l.substring(4).trim(); // e.g., 'b/path' or '/dev/null' + // Stop early after we've collected the header markers + if (headerOld && headerNew && (sawNewFile || sawDeletedFile || sawRenameFrom || sawRenameTo)) break; + } + + // Normalize paths like 'a/path' or 'b/path' + const normalize = (p) => { + if (!p) return null; + if (p === '/dev/null') return '/dev/null'; + // strip leading a/ or b/ + return p.replace(/^a\//, '').replace(/^b\//, ''); + }; + + const oldPath = normalize(headerOld); + const newPath = normalize(headerNew); + + // Decide change type priority: rename > added > deleted > modified + if (sawRenameFrom || sawRenameTo) { + fileDiff.changeType = 'renamed'; + fileDiff.oldPath = sawRenameFrom || oldPath || fileDiff.oldPath || null; + fileDiff.newPath = sawRenameTo || newPath || fileDiff.newPath || null; + fileDiff.filePath = fileDiff.newPath || fileDiff.filePath; + return; + } + + if (sawNewFile || oldPath === '/dev/null') { + fileDiff.changeType = 'added'; + fileDiff.oldPath = null; + fileDiff.newPath = newPath || fileDiff.newPath || fileDiff.filePath; + fileDiff.filePath = fileDiff.newPath; + return; + } + + if (sawDeletedFile || newPath === '/dev/null') { + fileDiff.changeType = 'deleted'; + fileDiff.newPath = null; + fileDiff.oldPath = oldPath || fileDiff.oldPath || fileDiff.filePath; + fileDiff.filePath = fileDiff.oldPath; + return; + } + + // Default to modified; refine filePath if headers present + fileDiff.changeType = 'modified'; + if (newPath && newPath !== '/dev/null') { + fileDiff.filePath = newPath; + } + if (!fileDiff.oldPath && oldPath && oldPath !== '/dev/null') fileDiff.oldPath = oldPath; + if (!fileDiff.newPath && newPath && newPath !== '/dev/null') fileDiff.newPath = newPath; + } + + // Process individual file diff + async processFileDiff(commitId, fileDiff) { + const diffSize = Buffer.byteLength(fileDiff.diffBody, 'utf8'); + const storageType = 'external'; + + console.log(`๐Ÿ“Š File ${fileDiff.filePath}: ${diffSize} bytes, storage: ${storageType}`); + + // Get file change record + const fileChangeQuery = ` + SELECT rcf.id + FROM repository_commit_files rcf + JOIN repository_commit_details rcd ON rcf.commit_id = rcd.id + WHERE rcd.id = $1 AND rcf.file_path = $2 + `; + + const fileChangeResult = await database.query(fileChangeQuery, [commitId, fileDiff.filePath]); + + if (fileChangeResult.rows.length === 0) { + throw new Error(`File change record not found for ${fileDiff.filePath}`); + } + + const fileChangeId = fileChangeResult.rows[0].id; + + // Store diff based on size + const diffContentId = await this.storeExternally(commitId, fileChangeId, fileDiff, diffSize); + + return { + success: true, + filePath: fileDiff.filePath, + changeType: fileDiff.changeType, + diffSize: diffSize, + storageType: storageType, + diffContentId: diffContentId + }; + } + + // Determine storage type (always external) + determineStorageType(size) { return 'external'; } + + // Removed DB storage path (all diffs stored externally) + + // Remove compression path; no longer used + + // Store large diff externally + async storeExternally(commitId, fileChangeId, fileDiff, diffSize) { + // Create directory structure: diffs/repo_id/commit_sha/ + const commitDir = path.join(this.diffStorageDir, commitId); + if (!fs.existsSync(commitDir)) { + fs.mkdirSync(commitDir, { recursive: true }); + } + + // Create safe filename + const safeFileName = fileDiff.filePath.replace(/[^a-zA-Z0-9._-]/g, '_') + '.diff'; + const filePath = path.join(commitDir, safeFileName); + + // Write diff to file + fs.writeFileSync(filePath, fileDiff.diffBody, 'utf8'); + + const query = ` + INSERT INTO diff_contents ( + commit_id, file_change_id, diff_header, diff_size_bytes, + storage_type, external_storage_path, external_storage_provider, + file_path, change_type, processing_status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `; + + const result = await database.query(query, [ + commitId, fileChangeId, fileDiff.diffHeader, diffSize, + 'external', filePath, 'local', + fileDiff.filePath, fileDiff.changeType, 'processed' + ]); + + return result.rows[0].id; + } + + // Update diff processing statistics + async updateDiffStatistics(repositoryId, results) { + const successful = results.filter(r => r.success); + const failed = results.filter(r => !r.success); + + const totalSize = successful.reduce((sum, r) => sum + (r.diffSize || 0), 0); + const avgSize = successful.length > 0 ? totalSize / successful.length : 0; + const externalCount = successful.filter(r => r.storageType === 'external').length; + + const query = ` + INSERT INTO diff_statistics ( + repository_id, period_start, period_end, + total_commits, total_files_changed, total_diffs_processed, + total_diff_size_bytes, avg_diff_size_bytes, max_diff_size_bytes, + diffs_stored_external, failed_processing_count + ) VALUES ($1, NOW() - INTERVAL '1 hour', NOW(), + 1, $2, $3, $4, $5, $6, $7, $8) + `; + + await database.query(query, [ + repositoryId, successful.length, successful.length, + totalSize, avgSize, Math.max(...successful.map(r => r.diffSize || 0), 0), + externalCount, failed.length + ]); + } + + // Queue diff processing for background processing + async queueDiffProcessing(commitId, repositoryId, repoPath, fromSha, toSha, priority = 0) { + const query = ` + INSERT INTO diff_processing_queue ( + commit_id, repository_id, from_sha, to_sha, repo_local_path, priority + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `; + + const result = await database.query(query, [ + commitId, repositoryId, fromSha, toSha, repoPath, priority + ]); + + return result.rows[0].id; + } + + // Get diff content for retrieval + async getDiffContent(diffContentId) { + const query = ` + SELECT storage_type, external_storage_path, file_path, change_type + FROM diff_contents + WHERE id = $1 + `; + + const result = await database.query(query, [diffContentId]); + + if (result.rows.length === 0) { + throw new Error(`Diff content not found: ${diffContentId}`); + } + + const diff = result.rows[0]; + + // We only support external storage now + return fs.readFileSync(diff.external_storage_path, 'utf8'); + } + + // Get diffs for a commit + async getCommitDiffs(commitId) { + const query = ` + SELECT dc.*, rcf.change_type as file_change_type + FROM diff_contents dc + JOIN repository_commit_files rcf ON dc.file_change_id = rcf.id + WHERE dc.commit_id = $1 + ORDER BY dc.file_path + `; + + const result = await database.query(query, [commitId]); + return result.rows; + } + + // Get repository diff statistics + async getRepositoryDiffStats(repositoryId, daysBack = 30) { + const query = ` + SELECT * FROM get_repository_diff_stats($1, $2) + `; + + const result = await database.query(query, [repositoryId, daysBack]); + return result.rows[0]; + } + + // Clean up old external diff files + async cleanupOldDiffs(daysBack = 30) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysBack); + + // Get old external diffs + const query = ` + SELECT external_storage_path + FROM diff_contents + WHERE storage_type = 'external' + AND created_at < $1 + `; + + const result = await database.query(query, [cutoffDate]); + + // Delete files + let deletedCount = 0; + for (const row of result.rows) { + try { + if (fs.existsSync(row.external_storage_path)) { + fs.unlinkSync(row.external_storage_path); + deletedCount++; + } + } catch (error) { + console.warn(`Failed to delete diff file ${row.external_storage_path}:`, error.message); + } + } + + // Delete database records + const deleteQuery = ` + DELETE FROM diff_contents + WHERE storage_type = 'external' + AND created_at < $1 + `; + + await database.query(deleteQuery, [cutoffDate]); + + return deletedCount; + } +} + +module.exports = DiffProcessingService; diff --git a/services/git-integration/src/services/enhanced-diff-processing.service.js b/services/git-integration/src/services/enhanced-diff-processing.service.js new file mode 100644 index 0000000..ff0fa63 --- /dev/null +++ b/services/git-integration/src/services/enhanced-diff-processing.service.js @@ -0,0 +1,336 @@ +const fs = require('fs').promises; +const path = require('path'); +const { execSync } = require('child_process'); +const DiffStorageModel = require('../models/diff-storage.model'); + +class EnhancedDiffProcessingService { + constructor() { + this.diffStorageModel = new DiffStorageModel(); + this.storageBasePath = process.env.DIFF_STORAGE_PATH || '/tmp/diffs'; + this.maxDiffSize = parseInt(process.env.MAX_DIFF_SIZE_BYTES) || 10 * 1024 * 1024; // 10MB default + } + + /** + * Initialize the diff processing service + */ + async initialize() { + try { + await fs.mkdir(this.storageBasePath, { recursive: true }); + console.log(`Diff storage initialized at: ${this.storageBasePath}`); + } catch (error) { + console.error('Error initializing diff storage:', error); + throw error; + } + } + + /** + * Process the next item in the diff processing queue + * @returns {Object|null} Processing result or null if no items + */ + async processNextQueueItem() { + try { + const queueItem = await this.diffStorageModel.getNextQueueItem(); + if (!queueItem) { + return null; + } + + console.log(`Processing diff queue item: ${queueItem.id}`); + + // Update status to processing + await this.diffStorageModel.updateQueueStatus(queueItem.id, 'processing'); + + try { + const result = await this.processDiffForCommit(queueItem); + + // Update status to completed + await this.diffStorageModel.updateQueueStatus(queueItem.id, 'completed'); + + return result; + } catch (error) { + console.error(`Error processing diff queue item ${queueItem.id}:`, error); + + // Increment retry count + const updatedItem = await this.diffStorageModel.incrementRetryCount(queueItem.id); + + // Check if we should retry or mark as failed + if (updatedItem.retry_count >= updatedItem.max_retries) { + await this.diffStorageModel.updateQueueStatus( + queueItem.id, + 'failed', + error.message + ); + } else { + await this.diffStorageModel.updateQueueStatus(queueItem.id, 'pending'); + } + + throw error; + } + } catch (error) { + console.error('Error processing queue item:', error); + throw error; + } + } + + /** + * Process diff for a specific commit + * @param {Object} queueItem - Queue item data + * @returns {Object} Processing result + */ + async processDiffForCommit(queueItem) { + const { commit_id, repository_id, from_sha, to_sha, repo_local_path } = queueItem; + + try { + // Generate diff using git + const diffOutput = await this.generateGitDiff(repo_local_path, from_sha, to_sha); + + if (!diffOutput || diffOutput.trim().length === 0) { + console.log(`No diff output for commit ${to_sha}`); + return { processed_files: 0, total_size: 0 }; + } + + // Parse diff output into individual file diffs + const fileDiffs = this.parseDiffOutput(diffOutput); + + const processedFiles = []; + let totalSize = 0; + + for (const fileDiff of fileDiffs) { + try { + const diffContent = await this.storeDiffContent( + commit_id, + repository_id, + fileDiff + ); + + processedFiles.push(diffContent); + totalSize += diffContent.diff_size_bytes; + } catch (error) { + console.error(`Error storing diff for file ${fileDiff.file_path}:`, error); + // Continue with other files + } + } + + console.log(`Processed ${processedFiles.length} file diffs, total size: ${totalSize} bytes`); + + return { + processed_files: processedFiles.length, + total_size: totalSize, + files: processedFiles + }; + } catch (error) { + console.error('Error processing diff for commit:', error); + throw error; + } + } + + /** + * Generate git diff using command line + * @param {string} repoPath - Repository path + * @param {string} fromSha - From commit SHA + * @param {string} toSha - To commit SHA + * @returns {string} Diff output + */ + async generateGitDiff(repoPath, fromSha, toSha) { + try { + const command = fromSha + ? `git -C "${repoPath}" diff ${fromSha}..${toSha}` + : `git -C "${repoPath}" show --format="" ${toSha}`; + + const diffOutput = execSync(command, { + encoding: 'utf8', + maxBuffer: this.maxDiffSize + }); + + return diffOutput; + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error(`Repository not found at path: ${repoPath}`); + } + throw new Error(`Git diff failed: ${error.message}`); + } + } + + /** + * Parse diff output into individual file diffs + * @param {string} diffOutput - Raw diff output + * @returns {Array} Array of file diff objects + */ + parseDiffOutput(diffOutput) { + const fileDiffs = []; + const lines = diffOutput.split('\n'); + + let currentFile = null; + let currentDiff = []; + + for (const line of lines) { + if (line.startsWith('diff --git ')) { + // Save previous file diff if exists + if (currentFile && currentDiff.length > 0) { + fileDiffs.push({ + ...currentFile, + diff_content: currentDiff.join('\n') + }); + } + + // Start new file diff + const match = line.match(/diff --git a\/(.+) b\/(.+)/); + if (match) { + currentFile = { + file_path: match[2], + diff_header: line, + change_type: 'modified' // Default, will be refined below + }; + currentDiff = [line]; + } + } else if (line.startsWith('new file mode')) { + if (currentFile) currentFile.change_type = 'added'; + currentDiff.push(line); + } else if (line.startsWith('deleted file mode')) { + if (currentFile) currentFile.change_type = 'deleted'; + currentDiff.push(line); + } else if (line.startsWith('rename from')) { + if (currentFile) currentFile.change_type = 'renamed'; + currentDiff.push(line); + } else { + currentDiff.push(line); + } + } + + // Don't forget the last file + if (currentFile && currentDiff.length > 0) { + fileDiffs.push({ + ...currentFile, + diff_content: currentDiff.join('\n') + }); + } + + return fileDiffs; + } + + /** + * Store diff content to external storage + * @param {string} commit_id - Commit ID + * @param {string} repository_id - Repository ID + * @param {Object} fileDiff - File diff data + * @returns {Object} Stored diff content record + */ + async storeDiffContent(commit_id, repository_id, fileDiff) { + try { + const diffSizeBytes = Buffer.byteLength(fileDiff.diff_content, 'utf8'); + + // Generate storage path + const fileName = `${commit_id}_${this.sanitizeFileName(fileDiff.file_path)}.diff`; + const storagePath = path.join(this.storageBasePath, repository_id, fileName); + + // Ensure directory exists + await fs.mkdir(path.dirname(storagePath), { recursive: true }); + + // Write diff content to file + await fs.writeFile(storagePath, fileDiff.diff_content, 'utf8'); + + // Create database record + const diffData = { + commit_id, + diff_header: fileDiff.diff_header, + diff_size_bytes: diffSizeBytes, + storage_type: 'external', + external_storage_path: storagePath, + external_storage_provider: 'local', + file_path: fileDiff.file_path, + change_type: fileDiff.change_type, + processing_status: 'processed' + }; + + const diffContent = await this.diffStorageModel.createDiffContent(diffData); + + console.log(`Stored diff for ${fileDiff.file_path} (${diffSizeBytes} bytes)`); + + return diffContent; + } catch (error) { + console.error('Error storing diff content:', error); + throw error; + } + } + + /** + * Retrieve diff content from storage + * @param {string} diff_id - Diff content ID + * @returns {string} Diff content + */ + async retrieveDiffContent(diff_id) { + try { + const diffRecord = await this.diffStorageModel.getDiffById(diff_id); + if (!diffRecord) { + throw new Error(`Diff record not found: ${diff_id}`); + } + + if (diffRecord.storage_type === 'external') { + const content = await fs.readFile(diffRecord.external_storage_path, 'utf8'); + return content; + } + + throw new Error(`Unsupported storage type: ${diffRecord.storage_type}`); + } catch (error) { + console.error('Error retrieving diff content:', error); + throw error; + } + } + + /** + * Start background processing of diff queue + * @param {number} intervalMs - Processing interval in milliseconds + */ + startBackgroundProcessing(intervalMs = 30000) { + console.log(`Starting background diff processing (interval: ${intervalMs}ms)`); + + const processQueue = async () => { + try { + const result = await this.processNextQueueItem(); + if (result) { + console.log(`Processed diff queue item: ${result.processed_files} files`); + } + } catch (error) { + console.error('Background diff processing error:', error); + } + }; + + // Process immediately and then on interval + processQueue(); + setInterval(processQueue, intervalMs); + } + + /** + * Clean up old diff files and queue entries + * @param {number} daysBack - Days to keep + * @returns {Object} Cleanup results + */ + async cleanupOldData(daysBack = 30) { + try { + // Clean up queue entries + const deletedQueueItems = await this.diffStorageModel.cleanupOldQueueEntries(7); + + // TODO: Clean up old diff files from storage + // This would require querying diff_contents for old records and deleting files + + console.log(`Cleanup completed: ${deletedQueueItems} queue items deleted`); + + return { + deleted_queue_items: deletedQueueItems + }; + } catch (error) { + console.error('Error during cleanup:', error); + throw error; + } + } + + /** + * Sanitize file name for storage + * @param {string} fileName - Original file name + * @returns {string} Sanitized file name + */ + sanitizeFileName(fileName) { + return fileName.replace(/[^a-zA-Z0-9._-]/g, '_'); + } +} + +module.exports = EnhancedDiffProcessingService; diff --git a/services/git-integration/src/services/enhanced-webhook.service.js b/services/git-integration/src/services/enhanced-webhook.service.js new file mode 100644 index 0000000..9d3c9dd --- /dev/null +++ b/services/git-integration/src/services/enhanced-webhook.service.js @@ -0,0 +1,509 @@ +const { v4: uuidv4 } = require('uuid'); +const database = require('../config/database'); +const CommitTrackingService = require('./commit-tracking.service'); + +class EnhancedWebhookService { + constructor() { + this.db = database; + this.commitTrackingService = new CommitTrackingService(); + } + + /** + * Process GitHub webhook + * @param {Object} payload - GitHub webhook payload + * @param {Object} headers - Request headers + * @returns {Object} Processing result + */ + async processGitHubWebhook(payload, headers) { + try { + const deliveryId = headers['x-github-delivery']; + const eventType = headers['x-github-event']; + + // Store webhook record + const webhookRecord = await this.storeGitHubWebhook({ + delivery_id: deliveryId, + event_type: eventType, + action: payload.action, + owner_name: payload.repository?.owner?.login, + repository_name: payload.repository?.name, + ref: payload.ref, + before_sha: payload.before, + after_sha: payload.after, + commit_count: payload.commits?.length || 0, + payload: payload + }); + + // Process based on event type + let processingResult = null; + + if (eventType === 'push') { + processingResult = await this.processPushEvent(payload, 'github'); + } else if (eventType === 'pull_request') { + processingResult = await this.processPullRequestEvent(payload, 'github'); + } + + // Update webhook as processed + await this.markWebhookProcessed(webhookRecord.id, 'github'); + + return { + success: true, + webhook_id: webhookRecord.id, + event_type: eventType, + processing_result: processingResult + }; + } catch (error) { + console.error('Error processing GitHub webhook:', error); + throw error; + } + } + + /** + * Process GitLab webhook + * @param {Object} payload - GitLab webhook payload + * @param {Object} headers - Request headers + * @returns {Object} Processing result + */ + async processGitLabWebhook(payload, headers) { + try { + const deliveryId = headers['x-gitlab-event-uuid']; + const eventType = headers['x-gitlab-event']; + + // Store webhook record + const webhookRecord = await this.storeGitLabWebhook({ + delivery_id: deliveryId, + event_type: eventType, + action: payload.object_kind, + owner_name: payload.project?.namespace || payload.user_username, + repository_name: payload.project?.name || payload.repository?.name, + ref: payload.ref, + before_sha: payload.before, + after_sha: payload.after, + commit_count: payload.total_commits_count || payload.commits?.length || 0, + payload: payload + }); + + // Process based on event type + let processingResult = null; + + if (eventType === 'Push Hook' || payload.object_kind === 'push') { + processingResult = await this.processPushEvent(payload, 'gitlab'); + } else if (eventType === 'Merge Request Hook' || payload.object_kind === 'merge_request') { + processingResult = await this.processPullRequestEvent(payload, 'gitlab'); + } + + return { + success: true, + webhook_id: webhookRecord.id, + event_type: eventType, + processing_result: processingResult + }; + } catch (error) { + console.error('Error processing GitLab webhook:', error); + throw error; + } + } + + /** + * Process Bitbucket webhook + * @param {Object} payload - Bitbucket webhook payload + * @param {Object} headers - Request headers + * @returns {Object} Processing result + */ + async processBitbucketWebhook(payload, headers) { + try { + const deliveryId = headers['x-request-uuid']; + const eventType = headers['x-event-key']; + + // Store webhook record + const webhookRecord = await this.storeBitbucketWebhook({ + delivery_id: deliveryId, + event_type: eventType, + action: payload.action || 'push', + owner_name: payload.repository?.owner?.username || payload.repository?.workspace?.slug, + repository_name: payload.repository?.name, + ref: payload.push?.changes?.[0]?.new?.name, + before_sha: payload.push?.changes?.[0]?.old?.target?.hash, + after_sha: payload.push?.changes?.[0]?.new?.target?.hash, + commit_count: payload.push?.changes?.[0]?.commits?.length || 0, + payload: payload + }); + + // Process based on event type + let processingResult = null; + + if (eventType === 'repo:push') { + processingResult = await this.processPushEvent(payload, 'bitbucket'); + } else if (eventType === 'pullrequest:created' || eventType === 'pullrequest:updated') { + processingResult = await this.processPullRequestEvent(payload, 'bitbucket'); + } + + return { + success: true, + webhook_id: webhookRecord.id, + event_type: eventType, + processing_result: processingResult + }; + } catch (error) { + console.error('Error processing Bitbucket webhook:', error); + throw error; + } + } + + /** + * Process Gitea webhook + * @param {Object} payload - Gitea webhook payload + * @param {Object} headers - Request headers + * @returns {Object} Processing result + */ + async processGiteaWebhook(payload, headers) { + try { + const deliveryId = headers['x-gitea-delivery']; + const eventType = headers['x-gitea-event']; + + // Store webhook record + const webhookRecord = await this.storeGiteaWebhook({ + delivery_id: deliveryId, + event_type: eventType, + action: payload.action, + owner_name: payload.repository?.owner?.login, + repository_name: payload.repository?.name, + ref: payload.ref, + before_sha: payload.before, + after_sha: payload.after, + commit_count: payload.commits?.length || 0, + payload: payload + }); + + // Process based on event type + let processingResult = null; + + if (eventType === 'push') { + processingResult = await this.processPushEvent(payload, 'gitea'); + } else if (eventType === 'pull_request') { + processingResult = await this.processPullRequestEvent(payload, 'gitea'); + } + + return { + success: true, + webhook_id: webhookRecord.id, + event_type: eventType, + processing_result: processingResult + }; + } catch (error) { + console.error('Error processing Gitea webhook:', error); + throw error; + } + } + + /** + * Process push event from any provider + * @param {Object} payload - Webhook payload + * @param {string} provider - Git provider (github, gitlab, bitbucket, gitea) + * @returns {Object} Processing result + */ + async processPushEvent(payload, provider) { + try { + // Find repository record + const repositoryId = await this.findRepositoryId(payload, provider); + + if (!repositoryId) { + console.warn(`Repository not found for ${provider} push event`); + return { processed_commits: 0, message: 'Repository not found' }; + } + + // Extract commits based on provider format + const commits = this.extractCommitsFromPayload(payload, provider); + + if (!commits || commits.length === 0) { + return { processed_commits: 0, message: 'No commits in payload' }; + } + + // Process commits using commit tracking service + const processedCommits = await this.commitTrackingService.processWebhookCommits( + { commits }, + repositoryId + ); + + // Update repository last sync info + await this.updateRepositorySync(repositoryId, payload, provider); + + return { + processed_commits: processedCommits.length, + commits: processedCommits, + repository_id: repositoryId + }; + } catch (error) { + console.error('Error processing push event:', error); + throw error; + } + } + + /** + * Process pull request event from any provider + * @param {Object} payload - Webhook payload + * @param {string} provider - Git provider + * @returns {Object} Processing result + */ + async processPullRequestEvent(payload, provider) { + try { + // For now, just log PR events - can be extended later + console.log(`Processing ${provider} pull request event:`, { + action: payload.action, + pr_number: payload.number || payload.pull_request?.number || payload.pullrequest?.id, + title: payload.pull_request?.title || payload.pullrequest?.title + }); + + return { + event_type: 'pull_request', + action: payload.action, + message: 'Pull request event logged' + }; + } catch (error) { + console.error('Error processing pull request event:', error); + throw error; + } + } + + /** + * Extract commits from webhook payload based on provider + * @param {Object} payload - Webhook payload + * @param {string} provider - Git provider + * @returns {Array} Normalized commits array + */ + extractCommitsFromPayload(payload, provider) { + switch (provider) { + case 'github': + case 'gitea': + return payload.commits || []; + + case 'gitlab': + return payload.commits || []; + + case 'bitbucket': + return payload.push?.changes?.[0]?.commits || []; + + default: + return []; + } + } + + /** + * Find repository ID based on webhook payload + * @param {Object} payload - Webhook payload + * @param {string} provider - Git provider + * @returns {string|null} Repository ID + */ + async findRepositoryId(payload, provider) { + try { + let ownerName, repositoryName; + + switch (provider) { + case 'github': + case 'gitea': + ownerName = payload.repository?.owner?.login; + repositoryName = payload.repository?.name; + break; + + case 'gitlab': + ownerName = payload.project?.namespace || payload.user_username; + repositoryName = payload.project?.name || payload.repository?.name; + break; + + case 'bitbucket': + ownerName = payload.repository?.owner?.username || payload.repository?.workspace?.slug; + repositoryName = payload.repository?.name; + break; + + default: + return null; + } + + if (!ownerName || !repositoryName) { + return null; + } + + const query = ` + SELECT id FROM github_repositories + WHERE owner_name = $1 AND repository_name = $2 + LIMIT 1 + `; + + const result = await this.db.query(query, [ownerName, repositoryName]); + return result.rows[0]?.id || null; + } catch (error) { + console.error('Error finding repository ID:', error); + return null; + } + } + + /** + * Update repository sync information + * @param {string} repositoryId - Repository ID + * @param {Object} payload - Webhook payload + * @param {string} provider - Git provider + */ + async updateRepositorySync(repositoryId, payload, provider) { + try { + let afterSha; + + switch (provider) { + case 'github': + case 'gitea': + afterSha = payload.after; + break; + case 'gitlab': + afterSha = payload.after; + break; + case 'bitbucket': + afterSha = payload.push?.changes?.[0]?.new?.target?.hash; + break; + } + + if (afterSha) { + const query = ` + UPDATE github_repositories + SET last_synced_at = NOW(), + last_synced_commit_sha = $2, + sync_status = 'completed' + WHERE id = $1 + `; + + await this.db.query(query, [repositoryId, afterSha]); + } + } catch (error) { + console.error('Error updating repository sync:', error); + // Don't throw - this is not critical + } + } + + // Webhook storage methods + async storeGitHubWebhook(webhookData) { + const query = ` + INSERT INTO github_webhooks ( + id, delivery_id, event_type, action, owner_name, repository_name, + ref, before_sha, after_sha, commit_count, payload, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + webhookData.delivery_id, + webhookData.event_type, + webhookData.action, + webhookData.owner_name, + webhookData.repository_name, + webhookData.ref, + webhookData.before_sha, + webhookData.after_sha, + webhookData.commit_count, + JSON.stringify(webhookData.payload) + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async storeGitLabWebhook(webhookData) { + const query = ` + INSERT INTO gitlab_webhooks ( + id, delivery_id, event_type, action, owner_name, repository_name, + ref, before_sha, after_sha, commit_count, payload, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + webhookData.delivery_id, + webhookData.event_type, + webhookData.action, + webhookData.owner_name, + webhookData.repository_name, + webhookData.ref, + webhookData.before_sha, + webhookData.after_sha, + webhookData.commit_count, + JSON.stringify(webhookData.payload) + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async storeBitbucketWebhook(webhookData) { + const query = ` + INSERT INTO bitbucket_webhooks ( + id, delivery_id, event_type, action, owner_name, repository_name, + ref, before_sha, after_sha, commit_count, payload, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + webhookData.delivery_id, + webhookData.event_type, + webhookData.action, + webhookData.owner_name, + webhookData.repository_name, + webhookData.ref, + webhookData.before_sha, + webhookData.after_sha, + webhookData.commit_count, + JSON.stringify(webhookData.payload) + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async storeGiteaWebhook(webhookData) { + const query = ` + INSERT INTO gitea_webhooks ( + id, delivery_id, event_type, action, owner_name, repository_name, + ref, before_sha, after_sha, commit_count, payload, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW()) + RETURNING * + `; + + const values = [ + uuidv4(), + webhookData.delivery_id, + webhookData.event_type, + webhookData.action, + webhookData.owner_name, + webhookData.repository_name, + webhookData.ref, + webhookData.before_sha, + webhookData.after_sha, + webhookData.commit_count, + JSON.stringify(webhookData.payload) + ]; + + const result = await this.db.query(query, values); + return result.rows[0]; + } + + async markWebhookProcessed(webhookId, provider) { + const tableMap = { + 'github': 'github_webhooks', + 'gitlab': 'gitlab_webhooks', + 'bitbucket': 'bitbucket_webhooks', + 'gitea': 'gitea_webhooks' + }; + + const tableName = tableMap[provider]; + if (!tableName) return; + + const query = ` + UPDATE ${tableName} + SET processed_at = NOW(), updated_at = NOW() + WHERE id = $1 + `; + + await this.db.query(query, [webhookId]); + } +} + +module.exports = EnhancedWebhookService; diff --git a/services/git-integration/src/services/file-storage.service.js b/services/git-integration/src/services/file-storage.service.js index ecc3c8e..f8f7850 100644 --- a/services/git-integration/src/services/file-storage.service.js +++ b/services/git-integration/src/services/file-storage.service.js @@ -178,10 +178,7 @@ class FileStorageService { const fileRecord = fileResult.rows[0]; - // Process file content if it's a text file and not too large (< 10MB) - if (!isBinary && stats.size < 10 * 1024 * 1024) { - await this.processFileContent(fileRecord.id, absolutePath, extension); - } + return fileRecord; @@ -191,36 +188,7 @@ class FileStorageService { } } - // Process and store file content - async processFileContent(fileId, absolutePath, extension) { - try { - const content = fs.readFileSync(absolutePath, 'utf-8'); - const lines = content.split('\n'); - const preview = content.substring(0, 1000); - const language = this.languageMap[extension] || 'text'; - - const contentQuery = ` - INSERT INTO repository_file_contents ( - file_id, content_text, content_preview, language_detected, - line_count, char_count - ) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (file_id) DO UPDATE SET - content_text = $2, - content_preview = $3, - language_detected = $4, - line_count = $5, - char_count = $6, - updated_at = NOW() - `; - - await database.query(contentQuery, [ - fileId, content, preview, language, lines.length, content.length - ]); - - } catch (error) { - console.warn(`Error processing file content for file ID ${fileId}:`, error.message); - } - } + // Complete storage process for a repository async completeRepositoryStorage(storageId) { @@ -307,10 +275,9 @@ class FileStorageService { // Get files in a directory async getDirectoryFiles(repositoryId, directoryPath = '') { const query = ` - SELECT rf.*, rfc.language_detected, rfc.line_count + SELECT rf.* FROM repository_files rf LEFT JOIN repository_directories rd ON rf.directory_id = rd.id - LEFT JOIN repository_file_contents rfc ON rf.id = rfc.file_id WHERE rf.repository_id = $1 AND rd.relative_path = $2 ORDER BY rf.filename `; @@ -321,19 +288,7 @@ class FileStorageService { // Search files by content async searchFileContent(repositoryId, searchQuery) { - const query = ` - SELECT rf.filename, rf.relative_path, rfc.language_detected, - ts_rank_cd(to_tsvector('english', rfc.content_text), plainto_tsquery('english', $2)) as rank - FROM repository_files rf - JOIN repository_file_contents rfc ON rf.id = rfc.file_id - WHERE rf.repository_id = $1 - AND to_tsvector('english', rfc.content_text) @@ plainto_tsquery('english', $2) - ORDER BY rank DESC, rf.filename - LIMIT 50 - `; - - const result = await database.query(query, [repositoryId, searchQuery]); - return result.rows; + return []; } // Utility methods @@ -373,7 +328,6 @@ class FileStorageService { // Clean up storage for a repository async cleanupRepositoryStorage(repositoryId) { const queries = [ - 'DELETE FROM repository_file_contents WHERE file_id IN (SELECT id FROM repository_files WHERE repository_id = $1)', 'DELETE FROM repository_files WHERE repository_id = $1', 'DELETE FROM repository_directories WHERE repository_id = $1', 'DELETE FROM repository_storage WHERE repository_id = $1' diff --git a/services/git-integration/src/services/git-repo.service.js b/services/git-integration/src/services/git-repo.service.js index 17f3375..17112ca 100644 --- a/services/git-integration/src/services/git-repo.service.js +++ b/services/git-integration/src/services/git-repo.service.js @@ -120,6 +120,20 @@ class GitRepoService { } async getDiff(repoPath, fromSha, toSha, options = { patch: true }) { + // Ensure both SHAs exist locally; if using shallow clone, fetch missing objects + try { + if (fromSha) { + await this.runGit(repoPath, ['cat-file', '-e', `${fromSha}^{commit}`]).catch(async () => { + await this.runGit(repoPath, ['fetch', '--depth', '200', 'origin', fromSha]); + }); + } + if (toSha && toSha !== 'HEAD') { + await this.runGit(repoPath, ['cat-file', '-e', `${toSha}^{commit}`]).catch(async () => { + await this.runGit(repoPath, ['fetch', '--depth', '200', 'origin', toSha]); + }); + } + } catch (_) {} + const range = fromSha && toSha ? `${fromSha}..${toSha}` : toSha ? `${toSha}^..${toSha}` : ''; const mode = options.patch ? '--patch' : '--name-status'; const args = ['diff', mode]; @@ -129,6 +143,12 @@ class GitRepoService { } async getChangedFilesSince(repoPath, sinceSha) { + // Ensure SHA exists locally in case of shallow clone + try { + await this.runGit(repoPath, ['cat-file', '-e', `${sinceSha}^{commit}`]).catch(async () => { + await this.runGit(repoPath, ['fetch', '--depth', '200', 'origin', sinceSha]); + }); + } catch (_) {} const output = await this.runGit(repoPath, ['diff', '--name-status', `${sinceSha}..HEAD`]); const lines = output.split('\n').filter(Boolean); return lines.map(line => { diff --git a/services/git-integration/src/services/gitea-oauth.js b/services/git-integration/src/services/gitea-oauth.js index 662a043..5add638 100644 --- a/services/git-integration/src/services/gitea-oauth.js +++ b/services/git-integration/src/services/gitea-oauth.js @@ -1,6 +1,8 @@ // services/gitea-oauth.js const database = require('../config/database'); +const axios = require('axios'); + class GiteaOAuthService { constructor() { this.clientId = process.env.GITEA_CLIENT_ID; @@ -17,42 +19,135 @@ class GiteaOAuthService { redirect_uri: this.redirectUri, response_type: 'code', // Request both user and repository read scopes - scope: 'read:user read:repository', + scope: 'read:user read:repository write:repository', state }); - return `${authUrl}?${params.toString()}`; + const fullUrl = `${authUrl}?${params.toString()}`; + console.log(`๐Ÿ”— [GITEA OAUTH] Generated auth URL: ${fullUrl}`); + return fullUrl; } async exchangeCodeForToken(code) { const tokenUrl = `${this.baseUrl}/login/oauth/access_token`; - const resp = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ + console.log(`๐Ÿ”„ [GITEA OAUTH] Exchanging code for token at: ${tokenUrl}`); + console.log(`๐Ÿ”ง [GITEA OAUTH] Config - Base URL: ${this.baseUrl}, Client ID: ${this.clientId?.substring(0, 8)}...`); + // Validate required configuration + if (!this.clientId) { + throw new Error('GITEA_CLIENT_ID is not configured'); + } + if (!this.clientSecret) { + throw new Error('GITEA_CLIENT_SECRET is not configured'); + } + try { + const response = await axios.post(tokenUrl, new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, code, grant_type: 'authorization_code', - redirect_uri: this.redirectUri - }) - }); - let data = null; - try { data = await resp.json(); } catch (_) { data = null; } - if (!resp.ok || data?.error) { - const detail = data?.error_description || data?.error || (await resp.text().catch(() => '')) || 'unknown_error'; - throw new Error(`Gitea token exchange failed: ${detail}`); + redirect_uri: this.redirectUri, + scope: 'read:user read:repository write:repository' + }), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'User-Agent': 'CodeNuk-GitIntegration/1.0' + }, + timeout: 30000, + maxRedirects: 0, + // Add network configuration to handle connectivity issues + httpsAgent: new (require('https').Agent)({ + keepAlive: true, + timeout: 30000, + // Force IPv4 to avoid IPv6 connectivity issues + family: 4 + }), + // Add retry configuration + validateStatus: function (status) { + return status >= 200 && status < 300; + } + }); + console.log(`๐Ÿ“ฅ [GITEA OAUTH] Response status: ${response.status} ${response.statusText}`); + console.log(`๐Ÿ“ฅ [GITEA OAUTH] Response data:`, response.data); + if (response.data.error) { + console.error(`โŒ [GITEA OAUTH] Token exchange failed:`, response.data); + throw new Error(response.data.error_description || response.data.error || 'Gitea token exchange failed'); + } + if (!response.data.access_token) { + console.error(`โŒ [GITEA OAUTH] No access token in response:`, response.data); + throw new Error('No access token received from Gitea OAuth'); + } + console.log(`โœ… [GITEA OAUTH] Token exchange successful`); + return response.data.access_token; + } catch (e) { + console.error(`โŒ [GITEA OAUTH] Token exchange error:`, e); + // Handle AggregateError (multiple network errors) + if (e.name === 'AggregateError' && e.errors && e.errors.length > 0) { + const firstError = e.errors[0]; + if (firstError.code === 'ETIMEDOUT') { + throw new Error(`Gitea OAuth timeout: Request to ${tokenUrl} timed out after 30 seconds`); + } else if (firstError.code === 'ENOTFOUND' || firstError.code === 'ECONNREFUSED') { + throw new Error(`Gitea OAuth network error: Cannot connect to ${this.baseUrl}. Please check your network connection and GITEA_BASE_URL configuration`); + } else { + throw new Error(`Gitea OAuth network error: ${firstError.message || 'Connection failed'}`); + } + } + if (e.code === 'ECONNABORTED' || e.message.includes('timeout')) { + throw new Error(`Gitea OAuth timeout: Request to ${tokenUrl} timed out after 30 seconds`); + } else if (e.code === 'ENOTFOUND' || e.code === 'ECONNREFUSED' || e.message.includes('Network Error')) { + throw new Error(`Gitea OAuth network error: Cannot connect to ${this.baseUrl}. Please check your network connection and GITEA_BASE_URL configuration`); + } else if (e.response) { + // Handle HTTP error responses + const status = e.response.status; + const data = e.response.data; + throw new Error(`Gitea OAuth HTTP error ${status}: ${JSON.stringify(data)}`); + } else { + throw new Error(`Gitea OAuth error: ${e.message || 'Unknown error occurred'}`); + } } - return data.access_token; + } async getUserInfo(accessToken) { - const resp = await fetch(`${this.baseUrl}/api/v1/user`, { headers: { Authorization: `Bearer ${accessToken}` } }); - if (!resp.ok) { - let txt = ''; - try { txt = await resp.text(); } catch (_) {} - throw new Error(`Failed to fetch Gitea user (status ${resp.status}): ${txt}`); + + const userUrl = `${this.baseUrl}/api/v1/user`; + console.log(`๐Ÿ”„ [GITEA OAUTH] Fetching user info from: ${userUrl}`); + try { + const response = await axios.get(userUrl, { + headers: { + 'Authorization': `token ${accessToken}`, + 'Accept': 'application/json', + 'User-Agent': 'CodeNuk-GitIntegration/1.0' + }, + timeout: 15000, + // Add network configuration to handle connectivity issues + httpsAgent: new (require('https').Agent)({ + keepAlive: true, + timeout: 15000, + // Force IPv4 to avoid IPv6 connectivity issues + family: 4 + }), + // Add retry configuration + validateStatus: function (status) { + return status >= 200 && status < 300; + } + }); + console.log(`๐Ÿ“ฅ [GITEA OAUTH] User info response status: ${response.status} ${response.statusText}`); + console.log(`โœ… [GITEA OAUTH] User info retrieved successfully for: ${response.data.login || response.data.username}`); + return response.data; + } catch (e) { + console.error(`โŒ [GITEA OAUTH] User info error:`, e); + if (e.response) { + console.error(`โŒ [GITEA OAUTH] User info failed:`, e.response.data); + throw new Error(`Failed to fetch Gitea user (${e.response.status}): ${JSON.stringify(e.response.data)}`); + } else if (e.code === 'ECONNABORTED' || e.message.includes('timeout')) { + throw new Error('Gitea user info timeout: Request timed out after 15 seconds'); + } else if (e.code === 'ENOTFOUND' || e.code === 'ECONNREFUSED' || e.message.includes('Network Error')) { + throw new Error(`Gitea user info network error: Cannot connect to ${this.baseUrl}`); + } else { + throw e; + } } - return await resp.json(); + } async storeToken(accessToken, user) { @@ -61,7 +156,7 @@ class GiteaOAuthService { VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET access_token = EXCLUDED.access_token, gitea_username = EXCLUDED.gitea_username, gitea_user_id = EXCLUDED.gitea_user_id, scopes = EXCLUDED.scopes, expires_at = EXCLUDED.expires_at, updated_at = NOW() RETURNING *`, - [accessToken, user.login, user.id, JSON.stringify(['read:repository']), null] + [accessToken, user.login, user.id, JSON.stringify(['read:user','read:repository']), null] ); return result.rows[0]; } diff --git a/services/git-integration/src/services/providers/bitbucket.adapter.js b/services/git-integration/src/services/providers/bitbucket.adapter.js index f9d8d81..f643c20 100644 --- a/services/git-integration/src/services/providers/bitbucket.adapter.js +++ b/services/git-integration/src/services/providers/bitbucket.adapter.js @@ -87,14 +87,28 @@ class BitbucketAdapter extends VcsProviderInterface { if (!callbackUrl) return { created: false, reason: 'missing_callback_url' }; const token = await this.oauth.getToken(); if (!token?.access_token) return { created: false, reason: 'missing_token' }; - // Bitbucket Cloud webhooks don't support shared secret directly; create basic push webhook - const resp = await fetch(`https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/hooks`, { + // Bitbucket Cloud requires repository:admin and webhook scopes + const hooksUrl = `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/hooks`; + // Avoid duplicates: list existing hooks first + try { + const listResp = await fetch(hooksUrl, { headers: { Authorization: `Bearer ${token.access_token}` } }); + if (listResp.ok) { + const data = await listResp.json(); + const existing = (data.values || []).find(h => h.url === callbackUrl); + if (existing) { + return { created: false, reason: 'already_exists', hook_id: existing.uuid || existing.id }; + } + } + } catch (_) {} + // Create push webhook + const resp = await fetch(hooksUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token.access_token}` }, body: JSON.stringify({ description: 'CodeNuk Git Integration', url: callbackUrl, active: true, events: ['repo:push'] }) }); if (resp.ok) { const d = await resp.json(); return { created: true, hook_id: d.uuid || d.id }; } - return { created: false, reason: `status_${resp.status}` }; + const detail = await resp.text().catch(() => ''); + return { created: false, reason: `status_${resp.status}`, detail }; } catch (e) { return { created: false, error: e.message }; } @@ -111,13 +125,28 @@ class BitbucketAdapter extends VcsProviderInterface { } else { repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, branch, this.host); } + // Fetch and fast-forward with auth header for private repos + let beforeSha = await this.gitRepoService.getHeadSha(repoPath); + let afterSha = beforeSha; + try { + if (token?.access_token) { + // Use extraheader for both fetch and pull + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'fetch', '--all', '--prune']); + await this.gitRepoService.runGit(repoPath, ['checkout', branch]); + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'pull', '--ff-only', 'origin', branch]); + } else { + await this.gitRepoService.fetchAndFastForward(repoPath, branch); + } + afterSha = await this.gitRepoService.getHeadSha(repoPath); + } catch (_) {} + storageRecord = await this.fileStorageService.initializeRepositoryStorage(repositoryId, repoPath); await this.fileStorageService.processDirectoryStructure(storageRecord.id, repositoryId, repoPath); const finalStorage = await this.fileStorageService.completeRepositoryStorage(storageRecord.id); // Get the current HEAD commit SHA and update the repository record try { - const headSha = await this.gitRepoService.getHeadSha(repoPath); + const headSha = afterSha || (await this.gitRepoService.getHeadSha(repoPath)); await database.query( 'UPDATE github_repositories SET last_synced_at = NOW(), last_synced_commit_sha = $1, updated_at = NOW() WHERE id = $2', [headSha, repositoryId] @@ -129,7 +158,7 @@ class BitbucketAdapter extends VcsProviderInterface { [repositoryId] ); } - return { success: true, method: 'git', targetDir: repoPath, storage: finalStorage }; + return { success: true, method: 'git', targetDir: repoPath, beforeSha, afterSha, storage: finalStorage }; } catch (e) { if (storageRecord) await this.fileStorageService.markStorageFailed(storageRecord.id, e.message); return { success: false, error: e.message }; @@ -148,11 +177,32 @@ class BitbucketAdapter extends VcsProviderInterface { async getRepositoryDiff(owner, repo, branch, fromSha, toSha) { const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); + // Fast-forward before diff; include auth for private repos + try { + const token = await this.oauth.getToken(); + if (token?.access_token) { + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'fetch', '--all', '--prune']); + await this.gitRepoService.runGit(repoPath, ['checkout', branch]); + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'pull', '--ff-only', 'origin', branch]); + } else { + await this.gitRepoService.fetchAndFastForward(repoPath, branch); + } + } catch (_) {} return await this.gitRepoService.getDiff(repoPath, fromSha || null, toSha || 'HEAD', { patch: true }); } async getRepositoryChangesSince(owner, repo, branch, sinceSha) { const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); + try { + const token = await this.oauth.getToken(); + if (token?.access_token) { + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'fetch', '--all', '--prune']); + await this.gitRepoService.runGit(repoPath, ['checkout', branch]); + await this.gitRepoService.runGit(repoPath, ['-c', `http.extraheader=Authorization: Bearer ${token.access_token}`, 'pull', '--ff-only', 'origin', branch]); + } else { + await this.gitRepoService.fetchAndFastForward(repoPath, branch); + } + } catch (_) {} return await this.gitRepoService.getChangedFilesSince(repoPath, sinceSha); } diff --git a/services/git-integration/src/services/providers/gitea.adapter.js b/services/git-integration/src/services/providers/gitea.adapter.js index fccdf72..e1b23d9 100644 --- a/services/git-integration/src/services/providers/gitea.adapter.js +++ b/services/git-integration/src/services/providers/gitea.adapter.js @@ -3,6 +3,8 @@ const VcsProviderInterface = require('../vcs-provider.interface'); const FileStorageService = require('../file-storage.service'); const GitRepoService = require('../git-repo.service'); const GiteaOAuthService = require('../gitea-oauth'); +const axios = require('axios'); +const https = require('https'); class GiteaAdapter extends VcsProviderInterface { constructor() { @@ -33,32 +35,89 @@ class GiteaAdapter extends VcsProviderInterface { const token = await this.oauth.getToken(); const base = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/$/, ''); + console.log(`๐Ÿ” [GITEA] Checking repository access for: ${owner}/${repo}`); + console.log(`๐Ÿ” [GITEA] Token available: ${!!token?.access_token}`); + console.log(`๐Ÿ” [GITEA] API base URL: ${base}`); + try { // Always try with authentication first (like GitHub behavior) if (token?.access_token) { - const resp = await fetch(`${base}/api/v1/repos/${owner}/${repo}`, { headers: { Authorization: `Bearer ${token.access_token}` } }); - if (resp.status === 200) { - const d = await resp.json(); + const url = `${base}/api/v1/repos/${owner}/${repo}`; + console.log(`๐Ÿ” [GITEA] Trying authenticated request to: ${url}`); + + const response = await axios.get(url, { + headers: { Authorization: `token ${token.access_token}` }, + httpsAgent: new https.Agent({ + keepAlive: true, + timeout: 15000, + family: 4 // Force IPv4 to avoid IPv6 connectivity issues + }), + timeout: 15000, + validateStatus: function (status) { + return status >= 200 && status < 300; // Only consider 2xx as success + } + }); + + console.log(`๐Ÿ” [GITEA] Authenticated response status: ${response.status}`); + + if (response.status === 200) { + const d = response.data; const isPrivate = !!d.private; + console.log(`โœ… [GITEA] Repository accessible via authentication, private: ${isPrivate}`); return { exists: true, isPrivate, hasAccess: true, requiresAuth: isPrivate }; + } else { + console.log(`โŒ [GITEA] Authenticated request failed with status: ${response.status}`); + console.log(`โŒ [GITEA] Error response: ${JSON.stringify(response.data)}`); } } // No token or token failed: try without authentication - const resp = await fetch(`${base}/api/v1/repos/${owner}/${repo}`); - if (resp.status === 200) { - const d = await resp.json(); + const url = `${base}/api/v1/repos/${owner}/${repo}`; + console.log(`๐Ÿ” [GITEA] Trying unauthenticated request to: ${url}`); + + const response = await axios.get(url, { + httpsAgent: new https.Agent({ + keepAlive: true, + timeout: 15000, + family: 4 // Force IPv4 to avoid IPv6 connectivity issues + }), + timeout: 15000, + validateStatus: function (status) { + return status >= 200 && status < 300; // Only consider 2xx as success + } + }); + + console.log(`๐Ÿ” [GITEA] Unauthenticated response status: ${response.status}`); + + if (response.status === 200) { + const d = response.data; + console.log(`โœ… [GITEA] Repository accessible without authentication, private: ${!!d.private}`); return { exists: true, isPrivate: !!d.private, hasAccess: true, requiresAuth: false }; } - if (resp.status === 404 || resp.status === 403) { - // Repository exists but requires authentication (like GitHub behavior) - return { exists: resp.status !== 404 ? true : false, isPrivate: true, hasAccess: false, requiresAuth: true }; - } } catch (error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + console.log(`โŒ [GITEA] Request failed with status: ${error.response.status}`); + console.log(`โŒ [GITEA] Error response: ${JSON.stringify(error.response.data)}`); + + if (error.response.status === 404 || error.response.status === 403) { + console.log(`๐Ÿ” [GITEA] Repository exists but requires authentication (status: ${error.response.status})`); + return { exists: error.response.status !== 404 ? true : false, isPrivate: true, hasAccess: false, requiresAuth: true }; + } + } else if (error.request) { + // The request was made but no response was received + console.log(`โŒ [GITEA] Network error: ${error.message}`); + } else { + // Something happened in setting up the request that triggered an Error + console.log(`โŒ [GITEA] Exception occurred: ${error.message}`); + } + // If any error occurs, assume repository requires authentication return { exists: false, isPrivate: null, hasAccess: false, requiresAuth: true, error: 'Repository not found or requires authentication' }; } + console.log(`โŒ [GITEA] Falling through to default error case`); return { exists: false, isPrivate: null, hasAccess: false, requiresAuth: true, error: 'Repository not found or requires authentication' }; } @@ -67,12 +126,22 @@ class GiteaAdapter extends VcsProviderInterface { const base = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/$/, ''); if (token?.access_token) { try { - const resp = await fetch(`${base}/api/v1/repos/${owner}/${repo}`, { headers: { Authorization: `Bearer ${token.access_token}` } }); - if (resp.ok) { - const d = await resp.json(); + const response = await axios.get(`${base}/api/v1/repos/${owner}/${repo}`, { + headers: { Authorization: `token ${token.access_token}` }, + httpsAgent: new https.Agent({ + keepAlive: true, + timeout: 15000, + family: 4 // Force IPv4 to avoid IPv6 connectivity issues + }), + timeout: 15000 + }); + if (response.status === 200) { + const d = response.data; return { full_name: d.full_name || `${owner}/${repo}`, visibility: d.private ? 'private' : 'public', default_branch: d.default_branch || 'main', updated_at: d.updated_at }; } - } catch (_) {} + } catch (error) { + console.log(`โŒ [GITEA] Failed to fetch repository metadata: ${error.message}`); + } } return { full_name: `${owner}/${repo}`, visibility: 'public', default_branch: 'main', updated_at: new Date().toISOString() }; } @@ -83,20 +152,92 @@ class GiteaAdapter extends VcsProviderInterface { async ensureRepositoryWebhook(owner, repo, callbackUrl) { try { - if (!callbackUrl) return { created: false, reason: 'missing_callback_url' }; + if (!callbackUrl) { + console.warn('โš ๏ธ [GITEA] Webhook callbackUrl not provided; skipping webhook creation'); + return { created: false, reason: 'missing_callback_url' }; + } + const token = await this.oauth.getToken(); - if (!token?.access_token) return { created: false, reason: 'missing_token' }; + if (!token?.access_token) { + console.warn('โš ๏ธ [GITEA] OAuth token not available; skipping webhook creation'); + return { created: false, reason: 'missing_token' }; + } + const base = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/$/, ''); const secret = process.env.GITEA_WEBHOOK_SECRET || ''; - const resp = await fetch(`${base}/api/v1/repos/${owner}/${repo}/hooks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token.access_token}` }, - body: JSON.stringify({ type: 'gitea', config: { url: callbackUrl, content_type: 'json', secret: secret || undefined }, events: ['push'], active: true }) + + console.log(`๐Ÿ”— [GITEA] Setting up webhook for ${owner}/${repo}`); + + // First, list existing hooks to avoid duplicates + try { + const listResponse = await axios.get(`${base}/api/v1/repos/${owner}/${repo}/hooks`, { + headers: { Authorization: `token ${token.access_token}` }, + httpsAgent: new https.Agent({ + keepAlive: true, + timeout: 15000, + family: 4 + }), + timeout: 15000 + }); + + if (listResponse.status === 200) { + const existingHooks = listResponse.data; + + // Check if a webhook with our callback URL already exists + const existingHook = existingHooks.find(hook => + hook.config && hook.config.url === callbackUrl + ); + + if (existingHook) { + console.log(`โœ… [GITEA] Webhook already exists (ID: ${existingHook.id})`); + return { created: false, reason: 'already_exists', hook_id: existingHook.id }; + } + } + } catch (error) { + console.warn('โš ๏ธ [GITEA] Could not list existing webhooks, continuing with creation attempt:', error.message); + } + + // Create new webhook + const response = await axios.post(`${base}/api/v1/repos/${owner}/${repo}/hooks`, { + type: 'gitea', + config: { + url: callbackUrl, + content_type: 'json', + secret: secret || undefined + }, + events: ['push'], + active: true + }, { + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${token.access_token}` + }, + httpsAgent: new https.Agent({ + keepAlive: true, + timeout: 15000, + family: 4 + }), + timeout: 15000 }); - if (resp.ok) { const d = await resp.json(); return { created: true, hook_id: d.id }; } - return { created: false, reason: `status_${resp.status}` }; - } catch (e) { - return { created: false, error: e.message }; + + if (response.status === 200 || response.status === 201) { + const hookData = response.data; + console.log(`โœ… [GITEA] Webhook created successfully (ID: ${hookData.id})`); + return { created: true, hook_id: hookData.id }; + } + + console.warn(`โš ๏ธ [GITEA] Webhook creation failed with status: ${response.status}`); + return { created: false, reason: `status_${response.status}` }; + + } catch (error) { + // Common cases: insufficient permissions, private repo without correct scope + if (error.response) { + console.warn('โš ๏ธ [GITEA] Webhook creation failed:', error.response.status, error.response.data?.message || error.message); + return { created: false, error: error.message, status: error.response.status }; + } else { + console.warn('โš ๏ธ [GITEA] Webhook creation failed:', error.message); + return { created: false, error: error.message }; + } } } @@ -111,13 +252,22 @@ class GiteaAdapter extends VcsProviderInterface { } else { repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, branch, this.host); } + // Fetch and fast-forward to ensure latest commits are present + let beforeSha = await this.gitRepoService.getHeadSha(repoPath); + let afterSha = beforeSha; + try { + const res = await this.gitRepoService.fetchAndFastForward(repoPath, branch); + beforeSha = res.beforeSha || beforeSha; + afterSha = res.afterSha || afterSha; + } catch (_) {} + storageRecord = await this.fileStorageService.initializeRepositoryStorage(repositoryId, repoPath); await this.fileStorageService.processDirectoryStructure(storageRecord.id, repositoryId, repoPath); const finalStorage = await this.fileStorageService.completeRepositoryStorage(storageRecord.id); // Get the current HEAD commit SHA and update the repository record try { - const headSha = await this.gitRepoService.getHeadSha(repoPath); + const headSha = afterSha || (await this.gitRepoService.getHeadSha(repoPath)); await database.query( 'UPDATE github_repositories SET last_synced_at = NOW(), last_synced_commit_sha = $1, updated_at = NOW() WHERE id = $2', [headSha, repositoryId] @@ -129,7 +279,7 @@ class GiteaAdapter extends VcsProviderInterface { [repositoryId] ); } - return { success: true, method: 'git', targetDir: repoPath, storage: finalStorage }; + return { success: true, method: 'git', targetDir: repoPath, beforeSha, afterSha, storage: finalStorage }; } catch (e) { if (storageRecord) await this.fileStorageService.markStorageFailed(storageRecord.id, e.message); return { success: false, error: e.message }; @@ -148,11 +298,14 @@ class GiteaAdapter extends VcsProviderInterface { async getRepositoryDiff(owner, repo, branch, fromSha, toSha) { const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); + // Proactively fetch latest to ensure SHAs exist + try { await this.gitRepoService.fetchAndFastForward(repoPath, branch); } catch (_) {} return await this.gitRepoService.getDiff(repoPath, fromSha || null, toSha || 'HEAD', { patch: true }); } async getRepositoryChangesSince(owner, repo, branch, sinceSha) { const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); + try { await this.gitRepoService.fetchAndFastForward(repoPath, branch); } catch (_) {} return await this.gitRepoService.getChangedFilesSince(repoPath, sinceSha); } diff --git a/services/git-integration/src/services/providers/gitlab.adapter.js b/services/git-integration/src/services/providers/gitlab.adapter.js index 91b5b3b..529f318 100644 --- a/services/git-integration/src/services/providers/gitlab.adapter.js +++ b/services/git-integration/src/services/providers/gitlab.adapter.js @@ -158,13 +158,126 @@ class GitlabAdapter extends VcsProviderInterface { } async getRepositoryDiff(owner, repo, branch, fromSha, toSha) { - const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); - return await this.gitRepoService.getDiff(repoPath, fromSha || null, toSha || 'HEAD', { patch: true }); + // Mirror robust GitHub behavior: ensure repo exists, handle main/master fallback, + // fetch required history for provided SHAs (handle shallow clones), then diff. + const preferredBranch = branch || 'main'; + const alternateBranch = preferredBranch === 'main' ? 'master' : 'main'; + + const fs = require('fs'); + const path = require('path'); + let repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, preferredBranch); + + // Ensure repo exists locally (prefer OAuth) + try { + const token = await this.oauth.getToken().catch(() => null); + try { + if (token?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth(owner, repo, preferredBranch, this.host, token.access_token, 'oauth2'); + } else { + repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, preferredBranch, this.host); + } + } catch (_) { + // Try alternate common default branch + try { + if (token?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth(owner, repo, alternateBranch, this.host, token.access_token, 'oauth2'); + } else { + repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, alternateBranch, this.host); + } + } catch (_) {} + } + + // If preferred path missing but alternate exists, use alternate + const altPath = this.gitRepoService.getLocalRepoPath(owner, repo, alternateBranch); + if ((!fs.existsSync(repoPath) || !fs.existsSync(path.join(repoPath, '.git'))) && fs.existsSync(altPath)) { + repoPath = altPath; + } + + // Fetch and checkout; attempt preferred then alternate + try { + await this.gitRepoService.fetchAndFastForward(repoPath, preferredBranch); + } catch (_) { + try { await this.gitRepoService.fetchAndFastForward(repoPath, alternateBranch); } catch (_) {} + } + + // Ensure both SHAs exist locally; if not, fetch them explicitly + const ensureShaPresent = async (sha) => { + if (!sha) return; + try { + await this.gitRepoService.runGit(repoPath, ['cat-file', '-e', `${sha}^{commit}`]); + } catch (_) { + // Try fetching just that object; if shallow, unshallow or fetch full history + try { await this.gitRepoService.runGit(repoPath, ['fetch', '--depth=2147483647', 'origin']); } catch (_) {} + try { await this.gitRepoService.runGit(repoPath, ['fetch', 'origin', sha]); } catch (_) {} + } + }; + await ensureShaPresent(fromSha || null); + await ensureShaPresent(toSha || null); + + return await this.gitRepoService.getDiff(repoPath, fromSha || null, toSha || 'HEAD', { patch: true }); + } catch (error) { + const attempted = [ + this.gitRepoService.getLocalRepoPath(owner, repo, preferredBranch), + this.gitRepoService.getLocalRepoPath(owner, repo, alternateBranch) + ].join(' | '); + throw new Error(`${error.message} (attempted paths: ${attempted})`); + } } async getRepositoryChangesSince(owner, repo, branch, sinceSha) { - const repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); - return await this.gitRepoService.getChangedFilesSince(repoPath, sinceSha); + const preferredBranch = branch || 'main'; + const alternateBranch = preferredBranch === 'main' ? 'master' : 'main'; + const fs = require('fs'); + const path = require('path'); + let repoPath = this.gitRepoService.getLocalRepoPath(owner, repo, preferredBranch); + + try { + const token = await this.oauth.getToken().catch(() => null); + try { + if (token?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth(owner, repo, preferredBranch, this.host, token.access_token, 'oauth2'); + } else { + repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, preferredBranch, this.host); + } + } catch (_) { + try { + if (token?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth(owner, repo, alternateBranch, this.host, token.access_token, 'oauth2'); + } else { + repoPath = await this.gitRepoService.cloneIfMissingWithHost(owner, repo, alternateBranch, this.host); + } + } catch (_) {} + } + + const altPath = this.gitRepoService.getLocalRepoPath(owner, repo, alternateBranch); + if ((!fs.existsSync(repoPath) || !fs.existsSync(path.join(repoPath, '.git'))) && fs.existsSync(altPath)) { + repoPath = altPath; + } + + try { + await this.gitRepoService.fetchAndFastForward(repoPath, preferredBranch); + } catch (_) { + try { await this.gitRepoService.fetchAndFastForward(repoPath, alternateBranch); } catch (_) {} + } + + // Ensure sinceSha exists locally + if (sinceSha) { + try { + await this.gitRepoService.runGit(repoPath, ['cat-file', '-e', `${sinceSha}^{commit}`]); + } catch (_) { + try { await this.gitRepoService.runGit(repoPath, ['fetch', '--depth=2147483647', 'origin']); } catch (_) {} + try { await this.gitRepoService.runGit(repoPath, ['fetch', 'origin', sinceSha]); } catch (_) {} + } + } + + return await this.gitRepoService.getChangedFilesSince(repoPath, sinceSha); + } catch (error) { + const attempted = [ + this.gitRepoService.getLocalRepoPath(owner, repo, preferredBranch), + this.gitRepoService.getLocalRepoPath(owner, repo, alternateBranch) + ].join(' | '); + throw new Error(`${error.message} (attempted paths: ${attempted})`); + } } async cleanupRepositoryStorage(repositoryId) { diff --git a/services/git-integration/src/services/vcs-webhook.service.js b/services/git-integration/src/services/vcs-webhook.service.js index 8eab88f..e9be9f0 100644 --- a/services/git-integration/src/services/vcs-webhook.service.js +++ b/services/git-integration/src/services/vcs-webhook.service.js @@ -1,11 +1,13 @@ // services/vcs-webhook.service.js const database = require('../config/database'); const providerRegistry = require('./provider-registry'); +const DiffProcessingService = require('./diff-processing.service'); class VcsWebhookService { constructor() { this._schemaChecked = false; this._webhookEventColumns = new Map(); + this.diffService = new DiffProcessingService(); } // Process webhook events for any VCS provider @@ -38,7 +40,32 @@ class VcsWebhookService { // Handle push events for any provider async handlePushEvent(providerKey, payload) { - const { repository, project, ref, commits, pusher, user } = payload; + let { repository, project, ref, commits, pusher, user } = payload; + + // Normalize Bitbucket push payload structure to the common fields + if (providerKey === 'bitbucket' && payload && payload.push && Array.isArray(payload.push.changes)) { + try { + const firstChange = payload.push.changes[0] || {}; + const newRef = firstChange.new || {}; + const oldRef = firstChange.old || {}; + // Branch name + const branchName = newRef.name || oldRef.name || null; + // Compose a git-like ref + ref = branchName ? `refs/heads/${branchName}` : ref; + // Aggregate commits across changes + const allCommits = []; + for (const change of payload.push.changes) { + if (Array.isArray(change.commits)) { + allCommits.push(...change.commits); + } + } + commits = allCommits; + // Surface before/after hashes for persistence + payload.before = oldRef.target?.hash || payload.before || null; + payload.after = newRef.target?.hash || payload.after || null; + // Bitbucket sets repository at top-level; keep as-is if present + } catch (_) {} + } // Build a provider-normalized repo object for extraction let repoForExtraction = repository || {}; @@ -85,12 +112,7 @@ class VcsWebhookService { }); if (repoId) { - // Insert into repository_commit_events - await database.query( - `INSERT INTO repository_commit_events (repository_id, ref, before_sha, after_sha, commit_count) - VALUES ($1, $2, $3, $4, $5)`, - [repoId, ref, payload.before || null, payload.after || null, commitData.length] - ); + // Persist per-commit details and file paths if (commitData.length > 0) { @@ -114,23 +136,25 @@ class VcsWebhookService { commit.url || null ] ); - const commitId = commitInsert.rows[0].id; - - // Insert file changes - const addFiles = (paths = [], changeType) => paths.forEach(async (p) => { - try { - await database.query( - `INSERT INTO repository_commit_files (commit_id, change_type, file_path) - VALUES ($1, $2, $3)`, - [commitId, changeType, p] - ); - } catch (_) {} - }); - - addFiles(commit.added || [], 'added'); - addFiles(commit.modified || [], 'modified'); - addFiles(commit.removed || [], 'removed'); + // For Bitbucket, we'll skip file change insertion during webhook processing + // since the webhook doesn't include file changes. The background sync will + // handle this properly by fetching the changes from git directly. + if (providerKey !== 'bitbucket') { + // For other providers (GitHub, GitLab, Gitea), use the webhook data + const addFiles = (paths = [], changeType) => paths.forEach(async (p) => { + try { + await database.query( + `INSERT INTO repository_commit_files (commit_id, change_type, file_path) + VALUES ($1, $2, $3)`, + [commitId, changeType, p] + ); + } catch (_) {} + }); + addFiles(commit.added || [], 'added'); + addFiles(commit.modified || [], 'modified'); + addFiles(commit.removed || [], 'removed'); + } } catch (commitErr) { console.warn('Failed to persist commit details:', commitErr.message); } @@ -157,6 +181,10 @@ class VcsWebhookService { repoId ); + // Process diffs for each commit after successful sync + if (downloadResult.success && downloadResult.targetDir) { + await this.processCommitDiffs(repoId, commitData, downloadResult.targetDir, providerKey); + } await database.query( 'UPDATE github_repositories SET sync_status = $1, last_synced_at = NOW(), updated_at = NOW() WHERE id = $2', [downloadResult.success ? 'synced' : 'error', repoId] @@ -250,9 +278,11 @@ class VcsWebhookService { }, message: commit.message, url: commit.links?.html?.href, - added: commit.added || [], - modified: commit.modified || [], - removed: commit.removed || [] + // Bitbucket webhook doesn't include file changes in commit objects + // We'll fetch these from git directly during processing + added: [], + modified: [], + removed: [] })); case 'gitea': return commits.map(commit => ({ @@ -361,94 +391,124 @@ class VcsWebhookService { console.log(`Repository: ${payload.repository?.full_name || payload.repository?.path_with_namespace || 'Unknown'}`); } - // Log webhook events for debugging and analytics - async logWebhookEvent(providerKey, eventType, action, repositoryFullName, metadata = {}, deliveryId = null, fullPayload = null) { - try { - await this._ensureWebhookEventsSchemaCached(); + // Process diffs for commits in a push event (same as GitHub webhook service) + async processCommitDiffs(repositoryId, commits, repoLocalPath, providerKey = 'github') { + console.log(`๐Ÿ”„ Processing diffs for ${commits.length} commits in repository ${repositoryId}`); - // Build a flexible INSERT based on existing columns - const columns = []; - const placeholders = []; - const values = []; - let i = 1; + if (!Array.isArray(commits) || commits.length === 0) { + console.log('โš ๏ธ No commits to process'); + return; + } - columns.push('event_type'); - placeholders.push(`$${i++}`); - values.push(eventType); + for (const commit of commits) { + try { + console.log(`๐Ÿ“ Processing diff for commit: ${commit.id}`); + const commitQuery = ` + SELECT id FROM repository_commit_details + WHERE repository_id = $1 AND commit_sha = $2 + `; + const commitResult = await database.query(commitQuery, [repositoryId, commit.id]); + if (commitResult.rows.length === 0) { + console.warn(`โš ๏ธ Commit ${commit.id} not found in database, skipping diff processing`); + continue; + } + const commitId = commitResult.rows[0].id; + const parentSha = commit.parents && commit.parents.length > 0 ? commit.parents[0].id : null; - if (this._webhookEventColumns.has('action')) { - columns.push('action'); - placeholders.push(`$${i++}`); - values.push(action || null); - } - if (this._webhookEventColumns.has('repository_full_name')) { - columns.push('repository_full_name'); - placeholders.push(`$${i++}`); - values.push(repositoryFullName || null); - } - if (this._webhookEventColumns.has('delivery_id')) { - columns.push('delivery_id'); - placeholders.push(`$${i++}`); - values.push(deliveryId || null); - } - if (this._webhookEventColumns.has('metadata')) { - columns.push('metadata'); - placeholders.push(`$${i++}`); - values.push(JSON.stringify({ ...metadata, provider: providerKey })); - } - if (this._webhookEventColumns.has('event_payload')) { - columns.push('event_payload'); - placeholders.push(`$${i++}`); - values.push(JSON.stringify(fullPayload || {})); - } - if (this._webhookEventColumns.has('received_at')) { - columns.push('received_at'); - placeholders.push(`$${i++}`); - values.push(new Date()); - } - if (this._webhookEventColumns.has('processing_status')) { - columns.push('processing_status'); - placeholders.push(`$${i++}`); - values.push('pending'); - } + // For Bitbucket, we need to ensure file changes are in the database first + if (providerKey === 'bitbucket') { + await this.ensureBitbucketFileChanges(commitId, commit, repoLocalPath); + } + const diffResult = await this.diffService.processCommitDiffs( + commitId, + repositoryId, + repoLocalPath, + parentSha, + commit.id + ); - const query = `INSERT INTO webhook_events (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`; - await database.query(query, values); - } catch (error) { - console.warn('Failed to log webhook event:', error.message); + if (diffResult.success) { + console.log(`โœ… Successfully processed ${diffResult.processedFiles} file diffs for commit ${commit.id}`); + } else { + console.warn(`โš ๏ธ Failed to process diffs for commit ${commit.id}: ${diffResult.error || diffResult.reason}`); + } + } catch (error) { + console.error(`โŒ Error processing diff for commit ${commit.id}:`, error.message); + + } } } - async _ensureWebhookEventsSchemaCached() { - if (this._schemaChecked) return; + // Ensure Bitbucket file changes are stored in the database + async ensureBitbucketFileChanges(commitId, commit, repoLocalPath) { try { - const result = await database.query( - "SELECT column_name, is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='webhook_events'" - ); - for (const row of result.rows) { - this._webhookEventColumns.set(row.column_name, row.is_nullable); - } - } catch (e) { - console.warn('Could not introspect webhook_events schema:', e.message); - } finally { - this._schemaChecked = true; - } - } - - // Get recent webhook events - async getRecentWebhookEvents(limit = 50) { - try { - const query = ` - SELECT * FROM webhook_events - ORDER BY received_at DESC - LIMIT $1 + // Check if file changes already exist for this commit + const existingFilesQuery = ` + SELECT COUNT(*) as count FROM repository_commit_files WHERE commit_id = $1 `; + const existingResult = await database.query(existingFilesQuery, [commitId]); - const result = await database.query(query, [limit]); - return result.rows; + if (parseInt(existingResult.rows[0].count) > 0) { + console.log(`๐Ÿ“ File changes already exist for commit ${commit.id}`); + return; + + } + + // Get file changes from git + const GitRepoService = require('./git-repo.service'); + const gitService = new GitRepoService(); + + const parentSha = commit.parents && commit.parents.length > 0 ? commit.parents[0].id : null; + const fromSha = parentSha || `${commit.id}~1`; + const fileChanges = await gitService.getChangedFilesSince(repoLocalPath, fromSha); + + // Parse the git diff output and categorize files + const added = []; + const modified = []; + const removed = []; + + for (const change of fileChanges) { + const { status, filePath } = change; + switch (status) { + case 'A': + added.push(filePath); + break; + case 'M': + modified.push(filePath); + break; + case 'D': + removed.push(filePath); + break; + case 'R': + // Renamed files - treat as modified for now + modified.push(filePath); + break; + default: + // Unknown status, treat as modified + modified.push(filePath); + } + } + // Insert file changes + const addFiles = (paths = [], changeType) => paths.forEach(async (p) => { + try { + await database.query( + `INSERT INTO repository_commit_files (commit_id, change_type, file_path) + VALUES ($1, $2, $3)`, + [commitId, changeType, p] + ); + } catch (err) { + console.warn(`Failed to insert file change: ${err.message}`); + } + }); + addFiles(added, 'added'); + addFiles(modified, 'modified'); + addFiles(removed, 'removed'); + + + console.log(`๐Ÿ“ Inserted ${added.length + modified.length + removed.length} file changes for Bitbucket commit ${commit.id}`); + } catch (error) { - console.error('Failed to get webhook events:', error.message); - return []; + console.warn(`Failed to ensure Bitbucket file changes for commit ${commit.id}:`, error.message); } } } diff --git a/services/git-integration/src/services/webhook.service.js b/services/git-integration/src/services/webhook.service.js index 2fbd1ca..d399e6c 100644 --- a/services/git-integration/src/services/webhook.service.js +++ b/services/git-integration/src/services/webhook.service.js @@ -2,6 +2,7 @@ const crypto = require('crypto'); const database = require('../config/database'); const GitHubIntegrationService = require('./github-integration.service'); +const DiffProcessingService = require('./diff-processing.service'); class WebhookService { constructor() { @@ -9,6 +10,7 @@ class WebhookService { this._schemaChecked = false; this._webhookEventColumns = new Map(); this.githubService = new GitHubIntegrationService(); + this.diffService = new DiffProcessingService(); } // Verify GitHub webhook signature @@ -97,11 +99,7 @@ class WebhookService { ); if (repoId) { - await database.query( - `INSERT INTO repository_commit_events (repository_id, ref, before_sha, after_sha, commit_count) - VALUES ($1, $2, $3, $4, $5)`, - [repoId, ref, payload.before || null, payload.after || null, Array.isArray(commits) ? commits.length : 0] - ); + // repository_commit_events table removed as requested // Persist per-commit details and file paths (added/modified/removed) if (Array.isArray(commits) && commits.length > 0) { @@ -165,6 +163,11 @@ class WebhookService { repoId ); + // Process diffs for each commit after successful sync + if (downloadResult.success && downloadResult.targetDir) { + await this.processCommitDiffs(repoId, commits, downloadResult.targetDir); + } + await database.query( 'UPDATE github_repositories SET sync_status = $1, last_synced_at = NOW(), updated_at = NOW() WHERE id = $2', [downloadResult.success ? 'synced' : 'error', repoId] @@ -265,97 +268,59 @@ class WebhookService { console.log(`Zen: ${payload.zen || 'No zen message'}`); } - // Log webhook events for debugging and analytics - async _ensureWebhookEventsSchemaCached() { - if (this._schemaChecked) return; - try { - const result = await database.query( - "SELECT column_name, is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='webhook_events'" - ); - for (const row of result.rows) { - this._webhookEventColumns.set(row.column_name, row.is_nullable); + // Process diffs for commits in a push event + async processCommitDiffs(repositoryId, commits, repoLocalPath) { + console.log(`๐Ÿ”„ Processing diffs for ${commits.length} commits in repository ${repositoryId}`); + + if (!Array.isArray(commits) || commits.length === 0) { + console.log('โš ๏ธ No commits to process'); + return; + } + + for (const commit of commits) { + try { + console.log(`๐Ÿ“ Processing diff for commit: ${commit.id}`); + + // Get commit record from database + const commitQuery = ` + SELECT id FROM repository_commit_details + WHERE repository_id = $1 AND commit_sha = $2 + `; + + const commitResult = await database.query(commitQuery, [repositoryId, commit.id]); + + if (commitResult.rows.length === 0) { + console.warn(`โš ๏ธ Commit ${commit.id} not found in database, skipping diff processing`); + continue; + } + + const commitId = commitResult.rows[0].id; + + // Get parent commit SHA for diff calculation + const parentSha = commit.parents && commit.parents.length > 0 ? commit.parents[0].id : null; + + // Process the diff + const diffResult = await this.diffService.processCommitDiffs( + commitId, + repositoryId, + repoLocalPath, + parentSha, + commit.id + ); + + if (diffResult.success) { + console.log(`โœ… Successfully processed ${diffResult.processedFiles} file diffs for commit ${commit.id}`); + } else { + console.warn(`โš ๏ธ Failed to process diffs for commit ${commit.id}: ${diffResult.error || diffResult.reason}`); + } + + } catch (error) { + console.error(`โŒ Error processing diff for commit ${commit.id}:`, error.message); } - } catch (e) { - // If schema check fails, proceed with best-effort insert - console.warn('Could not introspect webhook_events schema:', e.message); - } finally { - this._schemaChecked = true; } } - async logWebhookEvent(eventType, action, repositoryFullName, metadata = {}, deliveryId = null, fullPayload = null) { - try { - await this._ensureWebhookEventsSchemaCached(); - - // Build a flexible INSERT based on existing columns - const columns = []; - const placeholders = []; - const values = []; - let i = 1; - - columns.push('event_type'); - placeholders.push(`$${i++}`); - values.push(eventType); - - if (this._webhookEventColumns.has('action')) { - columns.push('action'); - placeholders.push(`$${i++}`); - values.push(action || null); - } - if (this._webhookEventColumns.has('repository_full_name')) { - columns.push('repository_full_name'); - placeholders.push(`$${i++}`); - values.push(repositoryFullName || null); - } - if (this._webhookEventColumns.has('delivery_id')) { - columns.push('delivery_id'); - placeholders.push(`$${i++}`); - values.push(deliveryId || null); - } - if (this._webhookEventColumns.has('metadata')) { - columns.push('metadata'); - placeholders.push(`$${i++}`); - values.push(JSON.stringify(metadata || {})); - } - if (this._webhookEventColumns.has('event_payload')) { - columns.push('event_payload'); - placeholders.push(`$${i++}`); - values.push(JSON.stringify(fullPayload || {})); - } - if (this._webhookEventColumns.has('received_at')) { - columns.push('received_at'); - placeholders.push(`$${i++}`); - values.push(new Date()); - } - if (this._webhookEventColumns.has('processing_status')) { - columns.push('processing_status'); - placeholders.push(`$${i++}`); - values.push('pending'); - } - - const query = `INSERT INTO webhook_events (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`; - await database.query(query, values); - } catch (error) { - console.warn('Failed to log webhook event:', error.message); - } - } - - // Get recent webhook events - async getRecentWebhookEvents(limit = 50) { - try { - const query = ` - SELECT * FROM webhook_events - ORDER BY received_at DESC - LIMIT $1 - `; - - const result = await database.query(query, [limit]); - return result.rows; - } catch (error) { - console.error('Failed to get webhook events:', error.message); - return []; - } - } + // webhook_events table removed as requested - logging functionality disabled } module.exports = WebhookService; diff --git a/services/git-integration/test-repo.js b/services/git-integration/test-repo.js deleted file mode 100644 index 295f445..0000000 --- a/services/git-integration/test-repo.js +++ /dev/null @@ -1,94 +0,0 @@ -const GitHubIntegrationService = require('./src/services/github-integration.service'); -const fs = require('fs'); -const path = require('path'); - -async function testRepository() { - try { - console.log('๐Ÿ” Testing GitHub Repository Integration...'); - - const githubService = new GitHubIntegrationService(); - const repositoryUrl = 'https://github.com/prakash6383206529/code-generator.git'; - - console.log(`๐Ÿ“‚ Testing repository: ${repositoryUrl}`); - - // Parse the GitHub URL - const { owner, repo, branch } = githubService.parseGitHubUrl(repositoryUrl); - console.log(`๐Ÿ“‹ Parsed URL - Owner: ${owner}, Repo: ${repo}, Branch: ${branch || 'main'}`); - - // Check if repository is public - try { - const unauthenticatedOctokit = new (require('@octokit/rest')).Octokit({ - userAgent: 'CodeNuk-GitIntegration/1.0.0', - }); - - const { data: repoInfo } = await unauthenticatedOctokit.repos.get({ owner, repo }); - const isPublic = !repoInfo.private; - - console.log(`๐Ÿ”“ Repository is ${isPublic ? 'public' : 'private'}`); - console.log(`๐Ÿ“Š Repository info:`); - console.log(` - Full Name: ${repoInfo.full_name}`); - console.log(` - Description: ${repoInfo.description || 'No description'}`); - console.log(` - Language: ${repoInfo.language || 'Unknown'}`); - console.log(` - Stars: ${repoInfo.stargazers_count}`); - console.log(` - Forks: ${repoInfo.forks_count}`); - console.log(` - Default Branch: ${repoInfo.default_branch}`); - console.log(` - Size: ${repoInfo.size} KB`); - console.log(` - Updated: ${repoInfo.updated_at}`); - - if (isPublic) { - console.log(`โœ… Repository is accessible and public`); - - // Try to analyze the codebase - console.log(`๐Ÿ” Analyzing codebase...`); - const analysis = await githubService.analyzeCodebase(owner, repo, repoInfo.default_branch, true); - console.log(`๐Ÿ“ˆ Codebase analysis completed:`); - console.log(` - Total Files: ${analysis.total_files}`); - console.log(` - Languages: ${Object.keys(analysis.languages).join(', ')}`); - console.log(` - Main Language: ${analysis.main_language}`); - console.log(` - Structure: ${analysis.structure ? 'Available' : 'Not available'}`); - - // Try to download the repository - console.log(`๐Ÿ“ฅ Attempting to download repository...`); - const downloadResult = await githubService.downloadRepository(owner, repo, repoInfo.default_branch); - - if (downloadResult.success) { - console.log(`โœ… Repository downloaded successfully!`); - console.log(`๐Ÿ“ Local path: ${downloadResult.local_path}`); - console.log(`๐Ÿ“Š Download stats:`); - console.log(` - Files: ${downloadResult.files_count}`); - console.log(` - Directories: ${downloadResult.directories_count}`); - console.log(` - Size: ${downloadResult.total_size_bytes} bytes`); - - // List some files - if (fs.existsSync(downloadResult.local_path)) { - const files = fs.readdirSync(downloadResult.local_path); - console.log(`๐Ÿ“„ Sample files in root:`); - files.slice(0, 10).forEach(file => { - const filePath = path.join(downloadResult.local_path, file); - const stat = fs.statSync(filePath); - console.log(` - ${file} (${stat.isDirectory() ? 'directory' : 'file'})`); - }); - } - } else { - console.log(`โŒ Repository download failed: ${downloadResult.error}`); - } - - } else { - console.log(`๐Ÿ”’ Repository is private - authentication required`); - } - - } catch (error) { - console.log(`โŒ Error accessing repository: ${error.message}`); - if (error.status === 404) { - console.log(`๐Ÿ” Repository might be private or not found`); - } - } - - } catch (error) { - console.error(`๐Ÿ’ฅ Test failed: ${error.message}`); - console.error(error.stack); - } -} - -// Run the test -testRepository(); diff --git a/services/git-integration/test-webhook.js b/services/git-integration/test-webhook.js deleted file mode 100644 index 8cdcf8d..0000000 --- a/services/git-integration/test-webhook.js +++ /dev/null @@ -1,70 +0,0 @@ -// test-webhook.js - Simple test script for webhook endpoint -const fetch = require('node-fetch'); - -const WEBHOOK_URL = 'http://localhost:8012/api/github/webhook'; - -// Test webhook with a sample GitHub push event -const testPayload = { - ref: 'refs/heads/main', - before: 'abc123', - after: 'def456', - repository: { - id: 123456, - name: 'test-repo', - full_name: 'testuser/test-repo', - owner: { - login: 'testuser', - id: 789 - } - }, - pusher: { - name: 'testuser', - email: 'test@example.com' - }, - commits: [ - { - id: 'def456', - message: 'Test commit', - author: { - name: 'Test User', - email: 'test@example.com' - } - } - ] -}; - -async function testWebhook() { - try { - console.log('๐Ÿงช Testing webhook endpoint...'); - console.log(`๐Ÿ“ก Sending POST request to: ${WEBHOOK_URL}`); - - const response = await fetch(WEBHOOK_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-GitHub-Event': 'push', - 'X-GitHub-Delivery': 'test-delivery-123' - }, - body: JSON.stringify(testPayload) - }); - - const result = await response.json(); - - console.log('๐Ÿ“Š Response Status:', response.status); - console.log('๐Ÿ“‹ Response Body:', JSON.stringify(result, null, 2)); - - if (response.ok) { - console.log('โœ… Webhook test successful!'); - } else { - console.log('โŒ Webhook test failed!'); - } - - } catch (error) { - console.error('โŒ Error testing webhook:', error.message); - } -} - -// Run the test -testWebhook(); - -