backend changes

This commit is contained in:
Chandini 2025-10-02 12:13:20 +05:30
parent 2f9c61bcc9
commit 7285c25aac
49 changed files with 5446 additions and 557 deletions

171
DEPLOYMENT_FIX_GUIDE.md Normal file
View File

@ -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

View File

@ -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;

View File

@ -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

156
scripts/fix-deployment-issues.sh Executable file
View File

@ -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"

View File

@ -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

View File

@ -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

Binary file not shown.

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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();
-- =============================================

View File

@ -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,

View File

@ -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

View File

@ -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),

View File

@ -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),

View File

@ -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;

View File

@ -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,

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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();

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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(

View File

@ -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;

View File

@ -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
});
}
})();
});

View File

@ -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,

View File

@ -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];
}

View File

@ -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;

View File

@ -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 <ATTACHED_REPOS_DIR>/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;

View File

@ -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;

View File

@ -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;

View File

@ -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'

View File

@ -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 => {

View File

@ -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];
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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) {

View File

@ -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);
}
}
}

View File

@ -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;

View File

@ -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();

View File

@ -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();