codenuk_backend_mine/services/git-integration/src/routes/github-integration.routes.js
2025-10-02 12:13:20 +05:30

1239 lines
46 KiB
JavaScript

// Updated routes/github-integration.js
const express = require('express');
const router = express.Router();
const GitHubIntegrationService = require('../services/github-integration.service');
const GitHubOAuthService = require('../services/github-oauth');
const FileStorageService = require('../services/file-storage.service');
const database = require('../config/database');
const fs = require('fs');
const path = require('path');
const githubService = new GitHubIntegrationService();
const oauthService = new GitHubOAuthService();
const fileStorageService = new FileStorageService();
// Helper function to generate authentication response
const generateAuthResponse = (res, repository_url, branch_name, userId) => {
try {
console.log('🔧 [generateAuthResponse] Starting auth response generation...');
const { owner, repo } = githubService.parseGitHubUrl(repository_url);
console.log('🔧 [generateAuthResponse] Parsed URL:', { owner, repo });
// Generate an auth URL that encodes the current user AND repo context so callback can auto-attach
const stateBase = Math.random().toString(36).substring(7);
const userIdForAuth = userId || null;
const encodedRepoUrl = encodeURIComponent(repository_url);
const encodedBranchName = encodeURIComponent(branch_name || '');
const state = `${stateBase}|uid=${userIdForAuth || ''}|repo=${encodedRepoUrl}|branch=${encodedBranchName}`;
console.log('🔧 [generateAuthResponse] Generated state:', state);
const rawAuthUrl = oauthService.getAuthUrl(state, userIdForAuth);
console.log('🔧 [generateAuthResponse] Generated raw auth URL:', rawAuthUrl);
const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000';
const serviceRelative = '/api/github/auth/github';
const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`;
console.log('🔧 [generateAuthResponse] Generated service auth URL:', serviceAuthUrl);
const response = {
success: false,
message: 'GitHub authentication required for private repository',
requires_auth: true,
auth_url: serviceAuthUrl,
service_auth_url: rawAuthUrl,
auth_error: false,
repository_info: {
owner,
repo,
repository_url,
branch_name: branch_name || 'main'
}
};
console.log('🔧 [generateAuthResponse] Sending response:', response);
return res.status(401).json(response);
} catch (error) {
console.error('❌ [generateAuthResponse] Error:', error);
return res.status(500).json({
success: false,
message: 'Error generating authentication URL',
error: error.message
});
}
};
// Attach GitHub repository to template
router.post('/attach-repository', async (req, res) => {
try {
const { repository_url, branch_name } = req.body;
const userId = req.headers['x-user-id'] || req.query.user_id || req.body.user_id || (req.user && (req.user.id || req.user.userId));
// Validate input
if (!repository_url) {
return res.status(400).json({
success: false,
message: 'Repository URL is required'
});
}
// Parse GitHub URL
const { owner, repo, branch } = githubService.parseGitHubUrl(repository_url);
// Step 1: Determine if repository is public or private
let isPublicRepo = false;
let repositoryData = null;
let hasAuth = false;
// Check if user has GitHub authentication first
try {
const authStatus = await oauthService.getAuthStatus();
hasAuth = authStatus.connected;
console.log(`🔐 User authentication status: ${hasAuth ? 'Connected' : 'Not connected'}`);
} catch (authError) {
console.log(`❌ Error checking auth status: ${authError.message}`);
hasAuth = false;
}
try {
// Try to access the repository without authentication first (for public repos)
const unauthenticatedOctokit = new (require('@octokit/rest')).Octokit({
userAgent: 'CodeNuk-GitIntegration/1.0.0',
});
const { data: repoInfo } = await unauthenticatedOctokit.repos.get({ owner, repo });
isPublicRepo = !repoInfo.private;
repositoryData = {
full_name: repoInfo.full_name,
description: repoInfo.description,
language: repoInfo.language,
visibility: repoInfo.private ? 'private' : 'public',
stargazers_count: repoInfo.stargazers_count,
forks_count: repoInfo.forks_count,
default_branch: repoInfo.default_branch,
size: repoInfo.size,
updated_at: repoInfo.updated_at
};
console.log(`✅ Repository ${owner}/${repo} is ${isPublicRepo ? 'public' : 'private'}`);
// If it's public, proceed with cloning
if (isPublicRepo) {
console.log(`📥 Proceeding to clone public repository ${owner}/${repo}`);
// Continue to cloning logic below
} else {
// It's private, check if user has authentication
console.log(`🔧 Debug: isPublicRepo = ${isPublicRepo}, hasAuth = ${hasAuth}`);
if (!hasAuth) {
console.log(`🔒 Private repository requires authentication - generating OAuth URL`);
console.log(`🔧 About to call generateAuthResponse with:`, { repository_url, branch_name, userId });
// Generate auth response inline to avoid hanging
console.log('🔧 [INLINE AUTH] Starting inline auth response generation...');
const { owner, repo } = githubService.parseGitHubUrl(repository_url);
console.log('🔧 [INLINE AUTH] Parsed URL:', { owner, repo });
const stateBase = Math.random().toString(36).substring(7);
const userIdForAuth = userId || null;
const encodedRepoUrl = encodeURIComponent(repository_url);
const encodedBranchName = encodeURIComponent(branch_name || '');
const state = `${stateBase}|uid=${userIdForAuth || ''}|repo=${encodedRepoUrl}|branch=${encodedBranchName}`;
console.log('🔧 [INLINE AUTH] Generated state:', state);
const rawAuthUrl = oauthService.getAuthUrl(state, userIdForAuth);
console.log('🔧 [INLINE AUTH] Generated raw auth URL:', rawAuthUrl);
const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000';
const serviceRelative = '/api/github/auth/github';
const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`;
console.log('🔧 [INLINE AUTH] Generated service auth URL:', serviceAuthUrl);
const response = {
success: false,
message: 'GitHub authentication required for private repository',
requires_auth: true,
auth_url: serviceAuthUrl,
service_auth_url: rawAuthUrl,
auth_error: false,
repository_info: {
owner,
repo,
repository_url,
branch_name: branch_name || 'main'
}
};
console.log('🔧 [INLINE AUTH] Sending response:', response);
return res.status(401).json(response);
} else {
console.log(`🔐 User has authentication for private repository - proceeding with authenticated access`);
// Continue to authenticated cloning logic below
}
}
} catch (error) {
// IMPORTANT: GitHub returns 404 for private repos when unauthenticated.
// Do NOT immediately return 404 here; instead continue to check auth and treat as potentially private.
if (error.status && error.status !== 404) {
// For non-404 errors (e.g., rate-limit, network), surface a meaningful message
console.warn(`Unauthenticated access failed with status ${error.status}: ${error.message}`);
}
// If we can't access it without auth (including 404), it's likely private
console.log(`❌ Cannot access ${owner}/${repo} without authentication (status=${error.status || 'unknown'})`);
console.log(`🔧 Debug: hasAuth = ${hasAuth}, userId = ${userId}`);
if (!hasAuth) {
console.log(`🔒 Repository appears to be private and user is not authenticated - generating OAuth URL`);
console.log(`🔧 About to call generateAuthResponse with:`, { repository_url, branch_name, userId });
// Generate auth response inline to avoid hanging
const { owner, repo } = githubService.parseGitHubUrl(repository_url);
const stateBase = Math.random().toString(36).substring(7);
const userIdForAuth = userId || null;
const encodedRepoUrl = encodeURIComponent(repository_url);
const encodedBranchName = encodeURIComponent(branch_name || '');
const state = `${stateBase}|uid=${userIdForAuth || ''}|repo=${encodedRepoUrl}|branch=${encodedBranchName}`;
const rawAuthUrl = oauthService.getAuthUrl(state, userIdForAuth);
const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000';
const serviceRelative = '/api/github/auth/github';
const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`;
return res.status(401).json({
success: false,
message: 'GitHub authentication required for private repository',
requires_auth: true,
auth_url: serviceAuthUrl,
service_auth_url: rawAuthUrl,
auth_error: false,
repository_info: {
owner,
repo,
repository_url,
branch_name: branch_name || 'main'
}
});
} else {
console.log(`🔐 User has authentication - trying authenticated access for potentially private repository`);
// Continue to authenticated access logic below
}
}
// Step 2: Handle authenticated access for private repositories
if (!isPublicRepo && hasAuth) {
try {
const octokit = await githubService.getAuthenticatedOctokit();
const { data: repoInfo } = await octokit.repos.get({ owner, repo });
repositoryData = {
full_name: repoInfo.full_name,
description: repoInfo.description,
language: repoInfo.language,
visibility: 'private',
stargazers_count: repoInfo.stargazers_count,
forks_count: repoInfo.forks_count,
default_branch: repoInfo.default_branch,
size: repoInfo.size,
updated_at: repoInfo.updated_at
};
console.log(`✅ Private repository ${owner}/${repo} accessed with authentication`);
} catch (authError) {
console.log(`❌ Cannot access ${owner}/${repo} even with authentication: ${authError.message}`);
return res.status(403).json({
success: false,
message: 'Repository not accessible - you may not have permission to access this repository'
});
}
}
// Step 3: Ensure we have repository data
if (!repositoryData) {
console.log(`❌ No repository data available - this should not happen`);
return res.status(500).json({
success: false,
message: 'Failed to retrieve repository information'
});
}
// Use the actual default branch from repository metadata if the requested branch doesn't exist
let actualBranch = branch || branch_name || repositoryData.default_branch || 'main';
// Validate that the requested branch exists, fallback to default if not
try {
if (branch || branch_name) {
// Use authenticated octokit for private repos, unauthenticated for public
let octokit;
if (isPublicRepo) {
octokit = new (require('@octokit/rest')).Octokit({
userAgent: 'CodeNuk-GitIntegration/1.0.0',
});
} else {
octokit = await githubService.getAuthenticatedOctokit();
}
await octokit.git.getRef({
owner,
repo,
ref: `heads/${actualBranch}`
});
}
} catch (error) {
if (error.status === 404) {
console.warn(`Branch ${actualBranch} not found, using default branch: ${repositoryData.default_branch}`);
actualBranch = repositoryData.default_branch || 'main';
} else {
throw error;
}
}
// Analyze the codebase
const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch, isPublicRepo);
// Store everything in PostgreSQL (without template_id)
const insertQuery = `
INSERT INTO github_repositories (
repository_url, repository_name, owner_name,
branch_name, is_public, metadata, codebase_analysis, sync_status,
requires_auth, user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`;
const insertValues = [
repository_url,
repo,
owner,
actualBranch,
isPublicRepo,
JSON.stringify(repositoryData),
JSON.stringify(codebaseAnalysis),
'syncing', // Start with syncing status
!isPublicRepo, // requires_auth is true for private repos
userId || null
];
const insertResult = await database.query(insertQuery, insertValues);
const repositoryRecord = insertResult.rows[0];
// Attempt to auto-create webhook on the attached repository using OAuth token (only for authenticated repos)
let webhookResult = null;
if (!isPublicRepo) {
const publicBaseUrl = process.env.PUBLIC_BASE_URL || null; // e.g., your ngrok URL https://xxx.ngrok-free.app
const callbackUrl = publicBaseUrl ? `${publicBaseUrl}/api/github/webhook` : null;
webhookResult = await githubService.ensureRepositoryWebhook(owner, repo, callbackUrl);
}
// Sync with fallback: try git first, then API
console.log(`Syncing ${isPublicRepo ? 'public' : 'private'} repository (git first, API fallback)...`);
const downloadResult = await githubService.syncRepositoryWithFallback(
owner, repo, actualBranch, repositoryRecord.id, isPublicRepo
);
// Update sync status based on download result
const finalSyncStatus = downloadResult.success ? 'synced' : 'error';
await database.query(
'UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2',
[finalSyncStatus, repositoryRecord.id]
);
if (!downloadResult.success) {
console.warn('Repository download failed:', downloadResult.error);
} else {
console.log(`✅ Repository ${owner}/${repo} synced successfully using ${downloadResult.method} method`);
}
// Get storage information
const storageInfo = await githubService.getRepositoryStorage(repositoryRecord.id);
res.status(201).json({
success: true,
message: `Repository attached and ${downloadResult.success ? 'synced' : 'partially synced'} successfully`,
data: {
repository_id: repositoryRecord.id,
repository_name: repositoryRecord.repository_name,
owner_name: repositoryRecord.owner_name,
branch_name: repositoryRecord.branch_name,
is_public: isPublicRepo,
requires_auth: !isPublicRepo,
sync_status: finalSyncStatus,
metadata: repositoryData,
codebase_analysis: codebaseAnalysis,
storage_info: storageInfo,
download_result: downloadResult,
webhook_result: webhookResult,
authentication_info: {
is_public: isPublicRepo,
authenticated: !isPublicRepo,
github_username: null
}
}
});
} catch (error) {
console.error('Error attaching repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to attach repository'
});
}
});
// Get repository commit summary (latest commit + total commit count + branch/tag counts)
router.get('/repository/:id/commit-summary', async (req, res) => {
try {
const { id } = req.params;
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Local repository path not found' });
}
const localPath = storageRes.rows[0].local_path;
const { execSync } = require('child_process');
const opts = { encoding: 'utf8' };
try { execSync(`git -C "${localPath}" rev-parse --is-inside-work-tree`, opts); } catch {
return res.status(400).json({ success: false, message: 'Path is not a git repository' });
}
let lastRaw = '';
try {
lastRaw = execSync(`git -C "${localPath}" log --pretty=format:%H|%an|%ae|%ad|%s -n 1 --date=iso`, opts).trim();
} catch (e) {
console.warn('[commit-summary] git log failed:', e?.message);
lastRaw = '';
}
let last_commit = null;
if (lastRaw) {
const [hash, author_name, author_email, committed_at, ...rest] = lastRaw.split('|');
const message = rest.join('|');
last_commit = { hash, short_hash: hash ? hash.substring(0,7) : null, author_name, author_email, committed_at, message };
} else {
// Fallback: use HEAD directly
try {
const head = execSync(`git -C "${localPath}" rev-parse HEAD`, opts).trim();
if (head) {
const show = execSync(`git -C "${localPath}" show -s --format=%H|%an|%ae|%ad|%s --date=iso ${head}`, opts).trim();
if (show) {
const [hash, author_name, author_email, committed_at, ...rest] = show.split('|');
const message = rest.join('|');
last_commit = { hash, short_hash: hash ? hash.substring(0,7) : null, author_name, author_email, committed_at, message };
}
}
} catch (e2) {
console.warn('[commit-summary] fallback rev-parse/show failed:', e2?.message);
}
}
let total_commits = 0;
try {
total_commits = parseInt(execSync(`git -C "${localPath}" rev-list --count HEAD`, opts).trim(), 10) || 0;
} catch { total_commits = 0; }
let branch_count = 0, tag_count = 0;
try { branch_count = execSync(`git -C "${localPath}" branch --list | wc -l`, opts).trim() * 1 || 0; } catch {}
try { tag_count = execSync(`git -C "${localPath}" tag --list | wc -l`, opts).trim() * 1 || 0; } catch {}
return res.json({ success: true, data: { last_commit, total_commits, branch_count, tag_count } });
} catch (error) {
console.error('Error getting commit summary:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to get commit summary' });
}
});
// Get last commit that touched a given path
router.get('/repository/:id/path-commit', async (req, res) => {
try {
const { id } = req.params;
const relPath = (req.query.path || '').toString();
if (!relPath) return res.status(400).json({ success: false, message: 'path is required' });
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Local repository path not found' });
}
const localPath = storageRes.rows[0].local_path;
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const opts = { encoding: 'utf8' };
const resolveCaseInsensitive = (base, rel) => {
const parts = rel.split('/').filter(Boolean);
let cur = base, acc = [];
for (const p of parts) {
if (!fs.existsSync(cur)) return null;
const entries = fs.readdirSync(cur);
const match = entries.find(e => e.toLowerCase() === p.toLowerCase());
if (!match) return null;
acc.push(match);
cur = path.join(cur, match);
}
return acc.join('/');
};
let resolvedRel = relPath;
const absCandidate = path.join(localPath, relPath);
if (!fs.existsSync(absCandidate)) {
const fixed = resolveCaseInsensitive(localPath, relPath);
if (fixed) resolvedRel = fixed; else return res.status(404).json({ success: false, message: 'Path not found' });
}
let raw = '';
try {
raw = execSync(`git -C "${localPath}" log --pretty=format:%H|%an|%ae|%ad|%s -n 1 --date=iso -- "${resolvedRel}"`, opts).trim();
} catch { raw = ''; }
if (!raw) return res.json({ success: true, data: null });
const [hash, author_name, author_email, committed_at, ...rest] = raw.split('|');
const message = rest.join('|');
return res.json({ success: true, data: { hash, short_hash: hash.substring(0,7), author_name, author_email, committed_at, message, path: resolvedRel } });
} catch (error) {
console.error('Error getting path commit:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to get path commit' });
}
});
// List commits with pagination and optional path filter (includes files changed)
router.get('/repository/:id/commits', async (req, res) => {
try {
const { id } = req.params;
const page = Math.max(1, parseInt((req.query.page || '1').toString(), 10));
const limit = Math.min(100, Math.max(1, parseInt((req.query.limit || '20').toString(), 10)));
const relPath = req.query.path ? req.query.path.toString() : '';
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Local repository path not found' });
}
const localPath = storageRes.rows[0].local_path;
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const opts = { encoding: 'utf8' };
// Count total
let countCmd = `git -C "${localPath}" rev-list --count HEAD`;
if (relPath) {
const candidate = path.join(localPath, relPath);
const exists = fs.existsSync(candidate);
if (!exists) {
// try to ignore if path missing; zero commits
return res.json({ success: true, data: { items: [], page, limit, total: 0, has_next: false } });
}
countCmd = `git -C "${localPath}" rev-list --count HEAD -- "${relPath}"`;
}
let total = 0;
try { total = parseInt(execSync(countCmd, opts).trim(), 10) || 0; } catch { total = 0; }
const skip = (page - 1) * limit;
// Use record separator \x1e for each commit block
let logCmd = `git -C "${localPath}" log --date=iso --pretty=format:%x1e%H|%an|%ae|%ad|%s --name-status --numstat --no-color --skip ${skip} -n ${limit}`;
if (relPath) logCmd += ` -- "${relPath}"`;
let raw = '';
try { raw = execSync(logCmd, opts); } catch { raw = ''; }
const blocks = raw.split('\x1e').map(b => b.trim()).filter(Boolean);
const items = blocks.map(block => {
const lines = block.split('\n').filter(Boolean);
const header = lines.shift() || '';
const [hash, author_name, author_email, committed_at, ...rest] = header.split('|');
const message = rest.join('|');
const fileMap = new Map();
for (const ln of lines) {
// numstat: additions\tdeletions\tpath
const numParts = ln.split('\t');
if (numParts.length === 3 && /^\d+|-$/u.test(numParts[0]) && /^\d+|-$/u.test(numParts[1])) {
const additions = numParts[0] === '-' ? null : parseInt(numParts[0], 10);
const deletions = numParts[1] === '-' ? null : parseInt(numParts[1], 10);
const fpath = numParts[2];
const entry = fileMap.get(fpath) || { path: fpath };
entry.additions = additions;
entry.deletions = deletions;
fileMap.set(fpath, entry);
continue;
}
// name-status: M\tpath or R100\told\tnew etc.
const ns = ln.split('\t');
if (ns.length >= 2) {
const status = ns[0];
let fpath = ns[1];
if (status.startsWith('R') && ns.length >= 3) {
// rename: old -> new
fpath = ns[2];
}
const entry = fileMap.get(fpath) || { path: fpath };
entry.change_type = status;
fileMap.set(fpath, entry);
}
}
const files = Array.from(fileMap.values());
return { hash, short_hash: hash?.substring(0,7), author_name, author_email, committed_at, message, files };
});
const has_next = skip + items.length < total;
return res.json({ success: true, data: { items, page, limit, total, has_next } });
} catch (error) {
console.error('Error listing commits:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to list commits' });
}
});
// Get a single commit by SHA with files changed
router.get('/repository/:id/commit/:sha', async (req, res) => {
try {
const { id, sha } = req.params;
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Local repository path not found' });
}
const localPath = storageRes.rows[0].local_path;
const { execSync } = require('child_process');
const opts = { encoding: 'utf8' };
const header = execSync(`git -C "${localPath}" show -s --format=%H|%an|%ae|%ad|%s --date=iso ${sha}`, opts).trim();
const [hash, author_name, author_email, committed_at, ...rest] = header.split('|');
const message = rest.join('|');
const filesRaw = execSync(`git -C "${localPath}" show --name-status --numstat --format= ${sha}`, opts);
const lines = filesRaw.split('\n').filter(Boolean);
const fileMap = new Map();
for (const ln of lines) {
const numParts = ln.split('\t');
if (numParts.length === 3 && /^\d+|-$/u.test(numParts[0]) && /^\d+|-$/u.test(numParts[1])) {
const additions = numParts[0] === '-' ? null : parseInt(numParts[0], 10);
const deletions = numParts[1] === '-' ? null : parseInt(numParts[1], 10);
const fpath = numParts[2];
const entry = fileMap.get(fpath) || { path: fpath };
entry.additions = additions;
entry.deletions = deletions;
fileMap.set(fpath, entry);
continue;
}
const ns = ln.split('\t');
if (ns.length >= 2) {
const status = ns[0];
let fpath = ns[1];
if (status.startsWith('R') && ns.length >= 3) {
fpath = ns[2];
}
const entry = fileMap.get(fpath) || { path: fpath };
entry.change_type = status;
fileMap.set(fpath, entry);
}
}
const files = Array.from(fileMap.values());
return res.json({ success: true, data: { hash, short_hash: hash?.substring(0,7), author_name, author_email, committed_at, message, files } });
} catch (error) {
console.error('Error getting commit by sha:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to get commit' });
}
});
// Get repository diff between two SHAs (unified patch)
router.get('/repository/:id/diff', async (req, res) => {
try {
const { id } = req.params;
const { from, to, path: dirPath } = req.query;
const repoQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const repoResult = await database.query(repoQuery, [id]);
if (repoResult.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Repository not found' });
}
const record = repoResult.rows[0];
const { owner, repo } = githubService.parseGitHubUrl(record.repository_url);
// Always use stored branch_name to avoid mismatches like master/main
const targetBranch = record.branch_name || 'main';
const patch = await githubService.getRepositoryDiff(owner, repo, targetBranch, from || record.last_synced_commit_sha, to || 'HEAD');
res.json({ success: true, data: { patch, from: from || record.last_synced_commit_sha, to: to || 'HEAD' } });
} catch (error) {
console.error('Error getting diff:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to get diff' });
}
});
// Get list of changed files since a SHA
router.get('/repository/:id/changes', async (req, res) => {
try {
const { id } = req.params;
const { since } = req.query;
const repoQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const repoResult = await database.query(repoQuery, [id]);
if (repoResult.rows.length === 0) {
return res.status(404).json({ success: false, message: 'Repository not found' });
}
const record = repoResult.rows[0];
const { owner, repo, branch } = githubService.parseGitHubUrl(record.repository_url);
const sinceSha = since || record.last_synced_commit_sha;
if (!sinceSha) {
return res.status(400).json({ success: false, message: 'since SHA is required or must be available as last_synced_commit_sha' });
}
const changes = await githubService.getRepositoryChangesSince(owner, repo, branch || record.branch_name, sinceSha);
res.json({ success: true, data: { since: sinceSha, changes } });
} catch (error) {
console.error('Error getting changes:', error);
res.status(500).json({ success: false, message: error.message || 'Failed to get changes' });
}
});
// Get repository information for a template
router.get('/template/:id/repository', async (req, res) => {
try {
const { id } = req.params;
const query = `
SELECT gr.*, rs.local_path, rs.storage_status, rs.total_files_count,
rs.total_directories_count, rs.total_size_bytes, rs.download_completed_at
FROM github_repositories gr
LEFT JOIN repository_storage rs ON gr.id = rs.repository_id
WHERE gr.template_id = $1
ORDER BY gr.created_at DESC
LIMIT 1
`;
const result = await database.query(query, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'No repository found for this template'
});
}
const repository = result.rows[0];
const parseMaybe = (v) => {
if (v == null) return {};
if (typeof v === 'string') {
try { return JSON.parse(v); } catch { return {}; }
}
return v; // already an object from jsonb
};
res.json({
success: true,
data: {
...repository,
metadata: parseMaybe(repository.metadata),
codebase_analysis: parseMaybe(repository.codebase_analysis)
}
});
} catch (error) {
console.error('Error fetching repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch repository'
});
}
});
// Get repository file structure
router.get('/repository/:id/structure', async (req, res) => {
try {
const { id } = req.params;
const { path: directoryPath } = req.query;
// Get repository info
const repoQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const repoResult = await database.query(repoQuery, [id]);
if (repoResult.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Repository not found'
});
}
let structure = [];
try {
structure = await fileStorageService.getRepositoryStructure(id, directoryPath);
} catch (e) {
console.warn('[structure] storage service failed, will try FS fallback:', e?.message);
}
// Filesystem fallback when storage has no entries
if (!Array.isArray(structure) || structure.length === 0) {
try {
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length > 0) {
const base = storageRes.rows[0].local_path;
const fs = require('fs');
const pth = require('path');
const resolveCaseInsensitive = (baseDir, rel) => {
if (!rel) return baseDir;
const parts = rel.split('/').filter(Boolean);
let cur = baseDir;
for (const p of parts) {
if (!fs.existsSync(cur)) return null;
const entries = fs.readdirSync(cur);
const match = entries.find(e => e.toLowerCase() === p.toLowerCase());
if (!match) return null;
cur = pth.join(cur, match);
}
return cur;
};
const absDir = resolveCaseInsensitive(base, directoryPath || '');
if (absDir && fs.existsSync(absDir) && fs.statSync(absDir).isDirectory()) {
const items = fs.readdirSync(absDir);
structure = items.map(name => {
const absChild = pth.join(absDir, name);
const isDir = fs.statSync(absChild).isDirectory();
// compute relative path from base
const relPath = pth.relative(base, absChild).replace(/\\/g, '/');
return {
name,
path: relPath,
type: isDir ? 'directory' : 'file'
};
}).sort((a, b) => {
// directories first, then alphabetical
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
}
} catch (fsErr) {
console.warn('[structure] FS fallback failed:', fsErr?.message);
}
}
res.json({
success: true,
data: {
repository_id: id,
directory_path: directoryPath || '',
structure: structure || []
}
});
} catch (error) {
console.error('Error fetching repository structure:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch repository structure'
});
}
});
// Get files in a directory
router.get('/repository/:id/files', async (req, res) => {
try {
const { id } = req.params;
const { directory_path = '' } = req.query;
// Get repository info
const repoQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const repoResult = await database.query(repoQuery, [id]);
if (repoResult.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Repository not found'
});
}
const files = await fileStorageService.getDirectoryFiles(id, directory_path);
res.json({
success: true,
data: {
repository_id: id,
directory_path: directory_path,
files: files
}
});
} catch (error) {
console.error('Error fetching directory files:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch directory files'
});
}
});
// Get file content
router.get('/repository/:id/file-content', async (req, res) => {
try {
const { id } = req.params;
const { file_path } = req.query;
if (!file_path) {
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
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]);
if (result.rows.length > 0) {
const file = result.rows[0];
return res.json({
success: true,
data: {
file_info: {
id: file.id,
filename: file.filename,
file_extension: file.file_extension,
relative_path: file.relative_path,
file_size_bytes: file.file_size_bytes,
mime_type: file.mime_type,
is_binary: file.is_binary,
language_detected: file.language_detected,
line_count: file.line_count,
char_count: file.char_count
},
content: file.is_binary ? null : file.content_text,
preview: file.content_preview
}
});
}
// Fallback: read from filesystem using repository_storage.local_path
const storageQ = `SELECT local_path FROM repository_storage WHERE repository_id = $1 ORDER BY created_at DESC LIMIT 1`;
const storageRes = await database.query(storageQ, [id]);
if (storageRes.rows.length === 0) {
return res.status(404).json({ success: false, message: 'File not found (no storage path)' });
}
const localBase = storageRes.rows[0].local_path;
const pathJoin = require('path').join;
const fs = require('fs');
// Helper: case-insensitive resolution
const resolveCaseInsensitive = (base, rel) => {
const parts = rel.split('/').filter(Boolean);
let cur = base;
for (const p of parts) {
if (!fs.existsSync(cur)) return null;
const entries = fs.readdirSync(cur);
const match = entries.find(e => e.toLowerCase() === p.toLowerCase());
if (!match) return null;
cur = pathJoin(cur, match);
}
return cur;
};
let absPath = pathJoin(localBase, file_path);
if (!fs.existsSync(absPath)) {
absPath = resolveCaseInsensitive(localBase, file_path);
}
if (!absPath || !fs.existsSync(absPath)) {
return res.status(404).json({ success: false, message: 'File not found' });
}
// Disallow directories for file-content
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
return res.status(400).json({ success: false, message: 'Requested path is a directory' });
}
// Basic binary detection
let buffer = fs.readFileSync(absPath);
let hasNull = buffer.includes(0);
let isBinary = hasNull;
const filename = require('path').basename(absPath);
const ext = require('path').extname(absPath).replace(/^\./, '') || null;
// Relax detection for well-known text extensions
const textExts = new Set(['txt','md','markdown','json','yml','yaml','xml','csv','tsv','py','js','jsx','ts','tsx','java','go','rb','rs','php','c','h','cc','hh','cpp','hpp','cs','kt','swift','sql','ini','env','sh','bash','zsh','bat','ps1','toml','gradle','makefile','dockerfile']);
if (ext && textExts.has(ext.toLowerCase())) {
isBinary = false;
hasNull = false;
}
const contentText = isBinary ? null : buffer.toString('utf8');
return res.json({
success: true,
data: {
file_info: {
id: null,
filename: filename,
file_extension: ext,
relative_path: file_path,
file_size_bytes: stat.size,
mime_type: null,
is_binary: isBinary,
language_detected: null,
line_count: contentText ? contentText.split(/\r?\n/).length : null,
char_count: contentText ? contentText.length : stat.size
},
content: contentText,
preview: contentText ? contentText.slice(0, 500) : null
}
});
} catch (error) {
console.error('Error fetching file content:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch file content'
});
}
});
// Search repository files
router.get('/repository/:id/search', async (req, res) => {
try {
const { id } = req.params;
const { q: query } = req.query;
if (!query) {
return res.status(400).json({
success: false,
message: 'Search query is required'
});
}
const results = await fileStorageService.searchFileContent(id, query);
res.json({
success: true,
data: {
repository_id: id,
search_query: query,
results: results,
total_results: results.length
}
});
} catch (error) {
console.error('Error searching repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to search repository'
});
}
});
// List all repositories for a template
router.get('/template/:id/repositories', async (req, res) => {
try {
const { id } = req.params;
const query = `
SELECT gr.*, rs.local_path, rs.storage_status, rs.total_files_count,
rs.total_directories_count, rs.total_size_bytes, rs.download_completed_at
FROM github_repositories gr
LEFT JOIN repository_storage rs ON gr.id = rs.repository_id
WHERE gr.template_id = $1
ORDER BY gr.created_at DESC
`;
const result = await database.query(query, [id]);
const repositories = result.rows.map(repo => ({
...repo,
metadata: JSON.parse(repo.metadata || '{}'),
codebase_analysis: JSON.parse(repo.codebase_analysis || '{}')
}));
res.json({
success: true,
data: repositories
});
} catch (error) {
console.error('Error fetching repositories:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch repositories'
});
}
});
// Download repository files (legacy endpoint for backward compatibility)
router.post('/download', async (req, res) => {
try {
const { repository_url, branch_name } = req.body;
if (!repository_url) {
return res.status(400).json({
success: false,
message: 'Repository URL is required'
});
}
const { owner, repo, branch } = githubService.parseGitHubUrl(repository_url);
const targetBranch = branch || branch_name || 'main';
const result = await githubService.downloadRepository(owner, repo, targetBranch);
if (result.success) {
res.json({
success: true,
message: 'Repository downloaded successfully',
data: result
});
} else {
res.status(500).json({
success: false,
message: 'Failed to download repository',
error: result.error
});
}
} catch (error) {
console.error('Error downloading repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to download repository'
});
}
});
// Re-sync repository (re-download and update database)
router.post('/repository/:id/sync', async (req, res) => {
try {
const { id } = req.params;
// Get repository info
const repoQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const repoResult = await database.query(repoQuery, [id]);
if (repoResult.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Repository not found'
});
}
const repository = repoResult.rows[0];
const { owner, repo, branch } = githubService.parseGitHubUrl(repository.repository_url);
// Clean up existing storage
await githubService.cleanupRepositoryStorage(id);
// Re-sync with fallback (git first, API fallback)
const downloadResult = await githubService.syncRepositoryWithFallback(
owner, repo, branch || repository.branch_name, id
);
// Update sync status
await database.query(
'UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2',
[downloadResult.success ? 'synced' : 'error', id]
);
res.json({
success: downloadResult.success,
message: downloadResult.success ? 'Repository synced successfully' : 'Failed to sync repository',
data: downloadResult
});
} catch (error) {
console.error('Error syncing repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to sync repository'
});
}
});
// Remove repository from template
router.delete('/repository/:id', async (req, res) => {
try {
const { id } = req.params;
// Get repository info before deletion
const getQuery = 'SELECT * FROM github_repositories WHERE id = $1';
const getResult = await database.query(getQuery, [id]);
if (getResult.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Repository not found'
});
}
const repository = getResult.rows[0];
// Clean up file storage
await githubService.cleanupRepositoryStorage(id);
// Delete repository record
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) {
console.error('Error removing repository:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch repositories'
});
}
});
// List all repositories for a user (by path param user_id)
router.get('/user/:user_id/repositories', async (req, res) => {
try {
const { user_id } = req.params;
const query = `
SELECT gr.*, rs.local_path, rs.storage_status, rs.total_files_count,
rs.total_directories_count, rs.total_size_bytes, rs.download_completed_at
FROM github_repositories gr
LEFT JOIN repository_storage rs ON gr.id = rs.repository_id
WHERE gr.user_id = $1
ORDER BY gr.created_at DESC
`;
const result = await database.query(query, [user_id]);
const parseMaybe = (v) => {
if (v == null) return {};
if (typeof v === 'string') { try { return JSON.parse(v); } catch { return {}; } }
return v; // already object from jsonb
};
const repositories = result.rows.map(repo => ({
...repo,
metadata: parseMaybe(repo.metadata),
codebase_analysis: parseMaybe(repo.codebase_analysis)
}));
res.json({
success: true,
data: repositories
});
} catch (error) {
console.error('Error fetching repositories:', error);
res.status(500).json({
success: false,
message: error.message || 'Failed to fetch repositories'
});
}
});
module.exports = router;