git integration backend changes
This commit is contained in:
parent
7eb2ab1dc2
commit
84736d86a8
@ -19,17 +19,6 @@ CREATE TABLE IF NOT EXISTS github_repositories (
|
|||||||
updated_at TIMESTAMP DEFAULT NOW()
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create table for feature-codebase mappings
|
|
||||||
CREATE TABLE IF NOT EXISTS feature_codebase_mappings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
template_id UUID REFERENCES templates(id) ON DELETE CASCADE,
|
|
||||||
feature_id UUID REFERENCES template_features(id) ON DELETE CASCADE,
|
|
||||||
repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE,
|
|
||||||
file_paths TEXT[],
|
|
||||||
code_snippets JSONB,
|
|
||||||
implementation_notes TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create indexes for better performance
|
-- 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_template_id ON github_repositories(template_id);
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
-- Create table if it does not exist (compatible with existing schemas)
|
|
||||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
event_type VARCHAR(100) NOT NULL,
|
|
||||||
processing_status VARCHAR(50) DEFAULT 'pending',
|
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Bring table to desired schema (idempotent)
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS action VARCHAR(100);
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS repository_full_name VARCHAR(255);
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS delivery_id VARCHAR(100);
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS metadata JSONB;
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS received_at TIMESTAMP DEFAULT NOW();
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS processed_at TIMESTAMP;
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS error_message TEXT;
|
|
||||||
ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
|
||||||
|
|
||||||
-- Create indexes (safe if columns now exist)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_event_type ON webhook_events(event_type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_repository ON webhook_events(repository_full_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_received_at ON webhook_events(received_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_delivery_id ON webhook_events(delivery_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(processing_status);
|
|
||||||
|
|
||||||
-- Add trigger to update timestamp
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_trigger WHERE tgname = 'update_webhook_events_updated_at'
|
|
||||||
) THEN
|
|
||||||
CREATE TRIGGER update_webhook_events_updated_at BEFORE UPDATE ON webhook_events
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@ -29,41 +29,204 @@ router.post('/attach-repository', async (req, res) => {
|
|||||||
// Parse GitHub URL
|
// Parse GitHub URL
|
||||||
const { owner, repo, branch } = githubService.parseGitHubUrl(repository_url);
|
const { owner, repo, branch } = githubService.parseGitHubUrl(repository_url);
|
||||||
|
|
||||||
// Check repository access with user-specific tokens
|
// First, try to check if this is a public repository without authentication
|
||||||
const accessCheck = await githubService.checkRepositoryAccessWithUser(owner, repo, userId);
|
let isPublicRepo = false;
|
||||||
|
let repositoryData = null;
|
||||||
|
|
||||||
if (!accessCheck.hasAccess) {
|
try {
|
||||||
if (accessCheck.requiresAuth || accessCheck.authError) {
|
// Try to access the repository without authentication first (for public repos)
|
||||||
// Generate an auth URL that encodes the current user and returns absolute via gateway
|
const unauthenticatedOctokit = new (require('@octokit/rest')).Octokit({
|
||||||
const state = Math.random().toString(36).substring(7);
|
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'}`);
|
||||||
|
} 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 - check if user has auth
|
||||||
|
console.log(`❌ Cannot access ${owner}/${repo} without authentication (status=${error.status || 'unknown'}), checking user auth...`);
|
||||||
|
|
||||||
|
// Check if user has GitHub authentication
|
||||||
|
let hasAuth = false;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user is not authenticated, first optimistically try to attach as PUBLIC via git clone.
|
||||||
|
// If cloning fails (e.g., due to private permissions), then prompt OAuth.
|
||||||
|
if (!hasAuth) {
|
||||||
|
try {
|
||||||
|
// Minimal metadata assuming public repo
|
||||||
|
repositoryData = {
|
||||||
|
full_name: `${owner}/${repo}`,
|
||||||
|
description: null,
|
||||||
|
language: null,
|
||||||
|
visibility: 'public',
|
||||||
|
stargazers_count: 0,
|
||||||
|
forks_count: 0,
|
||||||
|
default_branch: branch || branch_name || 'main',
|
||||||
|
size: 0,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
isPublicRepo = true;
|
||||||
|
|
||||||
|
// Determine branch fallback
|
||||||
|
let actualBranchLocal = repositoryData.default_branch || 'main';
|
||||||
|
if (branch_name) actualBranchLocal = branch_name;
|
||||||
|
|
||||||
|
// Store DB record before syncing (sync_status=syncing)
|
||||||
|
const insertQueryPublic = `
|
||||||
|
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 insertValuesPublic = [
|
||||||
|
repository_url,
|
||||||
|
repo,
|
||||||
|
owner,
|
||||||
|
actualBranchLocal,
|
||||||
|
true,
|
||||||
|
JSON.stringify(repositoryData),
|
||||||
|
JSON.stringify({ branch: actualBranchLocal, total_files: 0, total_size: 0 }),
|
||||||
|
'syncing',
|
||||||
|
false,
|
||||||
|
userId || null
|
||||||
|
];
|
||||||
|
const insertResPublic = await database.query(insertQueryPublic, insertValuesPublic);
|
||||||
|
const repositoryRecordPublic = insertResPublic.rows[0];
|
||||||
|
|
||||||
|
// Try to sync via git without auth (attempt preferred branch, then alternate if needed)
|
||||||
|
let downloadResultPublic = await githubService.syncRepositoryWithFallback(
|
||||||
|
owner, repo, actualBranchLocal, repositoryRecordPublic.id, true
|
||||||
|
);
|
||||||
|
if (!downloadResultPublic.success) {
|
||||||
|
const altBranch = actualBranchLocal === 'main' ? 'master' : 'main';
|
||||||
|
console.warn(`First public sync attempt failed on '${actualBranchLocal}', retrying with alternate branch '${altBranch}'...`);
|
||||||
|
downloadResultPublic = await githubService.syncRepositoryWithFallback(
|
||||||
|
owner, repo, altBranch, repositoryRecordPublic.id, true
|
||||||
|
);
|
||||||
|
if (downloadResultPublic.success) {
|
||||||
|
// Persist branch switch
|
||||||
|
await database.query('UPDATE github_repositories SET branch_name = $1, updated_at = NOW() WHERE id = $2', [altBranch, repositoryRecordPublic.id]);
|
||||||
|
actualBranchLocal = altBranch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSyncStatusPublic = downloadResultPublic.success ? 'synced' : 'error';
|
||||||
|
await database.query(
|
||||||
|
'UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2',
|
||||||
|
[finalSyncStatusPublic, repositoryRecordPublic.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadResultPublic.success) {
|
||||||
|
const storageInfoPublic = await githubService.getRepositoryStorage(repositoryRecordPublic.id);
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Repository attached and synced successfully (public, no auth)',
|
||||||
|
data: {
|
||||||
|
repository_id: repositoryRecordPublic.id,
|
||||||
|
repository_name: repositoryRecordPublic.repository_name,
|
||||||
|
owner_name: repositoryRecordPublic.owner_name,
|
||||||
|
branch_name: repositoryRecordPublic.branch_name,
|
||||||
|
is_public: true,
|
||||||
|
requires_auth: false,
|
||||||
|
sync_status: finalSyncStatusPublic,
|
||||||
|
metadata: repositoryData,
|
||||||
|
codebase_analysis: { branch: actualBranchLocal },
|
||||||
|
storage_info: storageInfoPublic,
|
||||||
|
download_result: downloadResultPublic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, public clone failed: likely private → prompt OAuth now
|
||||||
|
console.warn('Public clone attempt failed; switching to OAuth requirement');
|
||||||
|
} catch (probeErr) {
|
||||||
|
console.warn('Optimistic public attach failed:', probeErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 rawAuthUrl = oauthService.getAuthUrl(state, userIdForAuth);
|
||||||
|
|
||||||
// Prefer returning a gateway URL so frontend doesn't need to know service ports
|
|
||||||
const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000';
|
const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000';
|
||||||
const serviceRelative = '/api/github/auth/github';
|
const serviceRelative = '/api/github/auth/github';
|
||||||
// redirect=1 makes the endpoint issue a 302 directly to GitHub so UI doesn't land on JSON
|
|
||||||
const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`;
|
const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`;
|
||||||
|
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: accessCheck.error || 'GitHub authentication required for this repository',
|
message: 'GitHub authentication required or repository is private',
|
||||||
requires_auth: true,
|
requires_auth: true,
|
||||||
// Return both, frontend can pick the gateway URL
|
|
||||||
auth_url: serviceAuthUrl,
|
auth_url: serviceAuthUrl,
|
||||||
service_auth_url: rawAuthUrl,
|
service_auth_url: rawAuthUrl,
|
||||||
auth_error: accessCheck.authError || false
|
auth_error: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(404).json({
|
// User is authenticated, try to access the repository with auth
|
||||||
|
try {
|
||||||
|
const octokit = await githubService.getAuthenticatedOctokit();
|
||||||
|
const { data: repoInfo } = await octokit.repos.get({ owner, repo });
|
||||||
|
|
||||||
|
isPublicRepo = false; // This is a private 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,
|
success: false,
|
||||||
message: accessCheck.error || 'Repository not accessible'
|
message: 'Repository not accessible - you may not have permission to access this repository'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get repository information from GitHub
|
// If we don't have repository data yet (private repo), fetch it with authentication
|
||||||
const repositoryData = await githubService.fetchRepositoryMetadata(owner, repo);
|
if (!repositoryData) {
|
||||||
|
repositoryData = await githubService.fetchRepositoryMetadata(owner, repo);
|
||||||
|
}
|
||||||
|
|
||||||
// Use the actual default branch from repository metadata if the requested branch doesn't exist
|
// Use the actual default branch from repository metadata if the requested branch doesn't exist
|
||||||
let actualBranch = branch || branch_name || repositoryData.default_branch || 'main';
|
let actualBranch = branch || branch_name || repositoryData.default_branch || 'main';
|
||||||
@ -71,7 +234,16 @@ router.post('/attach-repository', async (req, res) => {
|
|||||||
// Validate that the requested branch exists, fallback to default if not
|
// Validate that the requested branch exists, fallback to default if not
|
||||||
try {
|
try {
|
||||||
if (branch || branch_name) {
|
if (branch || branch_name) {
|
||||||
const octokit = await githubService.getAuthenticatedOctokit();
|
// 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({
|
await octokit.git.getRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@ -88,7 +260,7 @@ router.post('/attach-repository', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Analyze the codebase
|
// Analyze the codebase
|
||||||
const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch);
|
const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch, isPublicRepo);
|
||||||
|
|
||||||
// Store everything in PostgreSQL (without template_id)
|
// Store everything in PostgreSQL (without template_id)
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
@ -105,31 +277,42 @@ router.post('/attach-repository', async (req, res) => {
|
|||||||
repo,
|
repo,
|
||||||
owner,
|
owner,
|
||||||
actualBranch,
|
actualBranch,
|
||||||
repositoryData.visibility === 'public',
|
isPublicRepo,
|
||||||
JSON.stringify(repositoryData),
|
JSON.stringify(repositoryData),
|
||||||
JSON.stringify(codebaseAnalysis),
|
JSON.stringify(codebaseAnalysis),
|
||||||
'synced',
|
'syncing', // Start with syncing status
|
||||||
accessCheck.requiresAuth,
|
!isPublicRepo, // requires_auth is true for private repos
|
||||||
userId || null
|
userId || null
|
||||||
];
|
];
|
||||||
|
|
||||||
const insertResult = await database.query(insertQuery, insertValues);
|
const insertResult = await database.query(insertQuery, insertValues);
|
||||||
const repositoryRecord = insertResult.rows[0];
|
const repositoryRecord = insertResult.rows[0];
|
||||||
|
|
||||||
// Attempt to auto-create webhook on the attached repository using OAuth token
|
// 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 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;
|
const callbackUrl = publicBaseUrl ? `${publicBaseUrl}/api/github/webhook` : null;
|
||||||
const webhookResult = await githubService.ensureRepositoryWebhook(owner, repo, callbackUrl);
|
webhookResult = await githubService.ensureRepositoryWebhook(owner, repo, callbackUrl);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync with fallback: try git first, then API
|
// Sync with fallback: try git first, then API
|
||||||
console.log('Syncing repository (git first, API fallback)...');
|
console.log(`Syncing ${isPublicRepo ? 'public' : 'private'} repository (git first, API fallback)...`);
|
||||||
const downloadResult = await githubService.syncRepositoryWithFallback(
|
const downloadResult = await githubService.syncRepositoryWithFallback(
|
||||||
owner, repo, actualBranch, repositoryRecord.id
|
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) {
|
if (!downloadResult.success) {
|
||||||
// If download failed, still return the repository record but mark the storage issue
|
|
||||||
console.warn('Repository download failed:', downloadResult.error);
|
console.warn('Repository download failed:', downloadResult.error);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Repository ${owner}/${repo} synced successfully using ${downloadResult.method} method`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get storage information
|
// Get storage information
|
||||||
@ -137,18 +320,25 @@ router.post('/attach-repository', async (req, res) => {
|
|||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Repository attached successfully',
|
message: `Repository attached and ${downloadResult.success ? 'synced' : 'partially synced'} successfully`,
|
||||||
data: {
|
data: {
|
||||||
repository_id: repositoryRecord.id,
|
repository_id: repositoryRecord.id,
|
||||||
repository_name: repositoryRecord.repository_name,
|
repository_name: repositoryRecord.repository_name,
|
||||||
owner_name: repositoryRecord.owner_name,
|
owner_name: repositoryRecord.owner_name,
|
||||||
branch_name: repositoryRecord.branch_name,
|
branch_name: repositoryRecord.branch_name,
|
||||||
is_public: repositoryRecord.is_public,
|
is_public: isPublicRepo,
|
||||||
requires_auth: repositoryRecord.requires_auth,
|
requires_auth: !isPublicRepo,
|
||||||
|
sync_status: finalSyncStatus,
|
||||||
metadata: repositoryData,
|
metadata: repositoryData,
|
||||||
codebase_analysis: codebaseAnalysis,
|
codebase_analysis: codebaseAnalysis,
|
||||||
storage_info: storageInfo,
|
storage_info: storageInfo,
|
||||||
download_result: downloadResult
|
download_result: downloadResult,
|
||||||
|
webhook_result: webhookResult,
|
||||||
|
authentication_info: {
|
||||||
|
is_public: isPublicRepo,
|
||||||
|
authenticated: !isPublicRepo,
|
||||||
|
github_username: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,10 @@ const oauthService = new GitHubOAuthService();
|
|||||||
// Initiate GitHub OAuth flow (supports optional user_id). If redirect=1, do 302 to GitHub.
|
// Initiate GitHub OAuth flow (supports optional user_id). If redirect=1, do 302 to GitHub.
|
||||||
router.get('/auth/github', async (req, res) => {
|
router.get('/auth/github', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = Math.random().toString(36).substring(7);
|
// If caller provided a state (e.g., containing repo context), use it; else generate one
|
||||||
|
const state = (typeof req.query.state === 'string' && req.query.state.length > 0)
|
||||||
|
? req.query.state
|
||||||
|
: Math.random().toString(36).substring(7);
|
||||||
const userId =
|
const userId =
|
||||||
req.query.user_id ||
|
req.query.user_id ||
|
||||||
(req.body && req.body.user_id) ||
|
(req.body && req.body.user_id) ||
|
||||||
@ -88,10 +91,67 @@ router.get('/auth/github/callback', async (req, res) => {
|
|||||||
console.log('[GitHub OAuth] callback about to store token for user_id =', user_id || null);
|
console.log('[GitHub OAuth] callback about to store token for user_id =', user_id || null);
|
||||||
const tokenRecord = await oauthService.storeToken(accessToken, githubUser, user_id || null);
|
const tokenRecord = await oauthService.storeToken(accessToken, githubUser, user_id || null);
|
||||||
|
|
||||||
|
// If the OAuth state contains repo context from the attach flow, auto-attach and start sync
|
||||||
|
// State format from attach: `${random}|uid=${userId}|repo=${encodeURIComponent(url)}|branch=${encodeURIComponent(branch)}`
|
||||||
|
let autoAttach = null;
|
||||||
|
try {
|
||||||
|
if (typeof state === 'string' && state.includes('|repo=')) {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
state.split('|').slice(1).map(kv => {
|
||||||
|
const [k, ...rest] = kv.split('=');
|
||||||
|
return [k, rest.join('=')];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const repoUrl = parts.repo ? decodeURIComponent(parts.repo) : null;
|
||||||
|
const branchName = parts.branch ? decodeURIComponent(parts.branch) : null;
|
||||||
|
if (repoUrl) {
|
||||||
|
console.log('[GitHub OAuth] Auto-attach requested for repo:', repoUrl, 'branch:', branchName || '(default)');
|
||||||
|
const GitHubIntegrationService = require('../services/github-integration.service');
|
||||||
|
const database = require('../config/database');
|
||||||
|
const githubService = new GitHubIntegrationService();
|
||||||
|
const { owner, repo, branch } = githubService.parseGitHubUrl(repoUrl);
|
||||||
|
// Get metadata using authenticated Octokit now that token is stored
|
||||||
|
const repositoryData = await githubService.fetchRepositoryMetadata(owner, repo);
|
||||||
|
let actualBranch = branchName || branch || repositoryData.default_branch || 'main';
|
||||||
|
// Attempt analysis and sync with fallback
|
||||||
|
const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch, false);
|
||||||
|
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 = [
|
||||||
|
repoUrl,
|
||||||
|
repo,
|
||||||
|
owner,
|
||||||
|
actualBranch,
|
||||||
|
repositoryData.visibility !== 'private',
|
||||||
|
JSON.stringify(repositoryData),
|
||||||
|
JSON.stringify(codebaseAnalysis),
|
||||||
|
'syncing',
|
||||||
|
repositoryData.visibility === 'private',
|
||||||
|
user_id || null,
|
||||||
|
];
|
||||||
|
const insertResult = await database.query(insertQuery, insertValues);
|
||||||
|
const repositoryRecord = insertResult.rows[0];
|
||||||
|
// Try to sync
|
||||||
|
const downloadResult = await githubService.syncRepositoryWithFallback(owner, repo, actualBranch, repositoryRecord.id, repositoryData.visibility !== 'private');
|
||||||
|
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]);
|
||||||
|
autoAttach = { repository_id: repositoryRecord.id, sync_status: finalSyncStatus };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (autoErr) {
|
||||||
|
console.warn('[GitHub OAuth] Auto-attach failed:', autoErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect back to frontend if configured
|
// Redirect back to frontend if configured
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
try {
|
try {
|
||||||
const redirectUrl = `${frontendUrl}/project-builder?github_connected=1&user=${encodeURIComponent(githubUser.login)}`;
|
const redirectUrl = `${frontendUrl}/project-builder?github_connected=1&user=${encodeURIComponent(githubUser.login)}${autoAttach ? `&repo_attached=1&repository_id=${encodeURIComponent(autoAttach.repository_id)}&sync_status=${encodeURIComponent(autoAttach.sync_status)}` : ''}`;
|
||||||
return res.redirect(302, redirectUrl);
|
return res.redirect(302, redirectUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback to JSON if redirect fails
|
// Fallback to JSON if redirect fails
|
||||||
@ -101,7 +161,8 @@ router.get('/auth/github/callback', async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
github_username: githubUser.login,
|
github_username: githubUser.login,
|
||||||
github_user_id: githubUser.id,
|
github_user_id: githubUser.id,
|
||||||
connected_at: tokenRecord.created_at
|
connected_at: tokenRecord.created_at,
|
||||||
|
auto_attach: autoAttach || null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -168,36 +168,71 @@ class GitHubIntegrationService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No token found that can access this repository
|
// No token found - try unauthenticated access first to check if it's public
|
||||||
|
try {
|
||||||
|
const unauthenticatedOctokit = new Octokit({
|
||||||
|
userAgent: 'CodeNuk-GitIntegration/1.0.0',
|
||||||
|
});
|
||||||
|
const { data } = await unauthenticatedOctokit.repos.get({ owner, repo });
|
||||||
|
|
||||||
|
// Repository exists and is public
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
isPrivate: false,
|
||||||
|
hasAccess: true,
|
||||||
|
requiresAuth: false,
|
||||||
|
github_username: null,
|
||||||
|
token_id: null
|
||||||
|
};
|
||||||
|
} catch (unauthenticatedError) {
|
||||||
|
if (unauthenticatedError.status === 404) {
|
||||||
|
// Repository truly doesn't exist
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
isPrivate: null,
|
||||||
|
hasAccess: false,
|
||||||
|
requiresAuth: false,
|
||||||
|
error: 'Repository not found'
|
||||||
|
};
|
||||||
|
} else if (unauthenticatedError.status === 401 || unauthenticatedError.status === 403) {
|
||||||
|
// Repository exists but requires authentication (private) - generate auth URL
|
||||||
|
const authUrl = await this.oauthService.generateAuthUrl(userId);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
isPrivate: true,
|
||||||
|
hasAccess: false,
|
||||||
|
requiresAuth: true,
|
||||||
|
error: 'Private repository requires authentication',
|
||||||
|
authError: false,
|
||||||
|
auth_url: authUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error - treat as private repository requiring auth - generate auth URL
|
||||||
|
const authUrl = await this.oauthService.generateAuthUrl(userId);
|
||||||
return {
|
return {
|
||||||
exists: null,
|
exists: null,
|
||||||
isPrivate: null,
|
isPrivate: null,
|
||||||
hasAccess: false,
|
hasAccess: false,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
error: 'Repository not found or requires authentication',
|
error: 'Repository requires authentication',
|
||||||
authError: false
|
authError: false,
|
||||||
};
|
auth_url: authUrl
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 404) {
|
|
||||||
return {
|
|
||||||
exists: false,
|
|
||||||
isPrivate: null,
|
|
||||||
hasAccess: false,
|
|
||||||
requiresAuth: true,
|
|
||||||
error: 'Repository not found or requires authentication'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle authentication errors
|
} catch (error) {
|
||||||
|
// Handle authentication errors - generate auth URL
|
||||||
if (error.status === 401 || error.message.includes('token has expired') || error.message.includes('authenticate with GitHub')) {
|
if (error.status === 401 || error.message.includes('token has expired') || error.message.includes('authenticate with GitHub')) {
|
||||||
|
const authUrl = await this.oauthService.generateAuthUrl(userId);
|
||||||
return {
|
return {
|
||||||
exists: null,
|
exists: null,
|
||||||
isPrivate: null,
|
isPrivate: null,
|
||||||
hasAccess: false,
|
hasAccess: false,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
error: 'GitHub authentication required or token expired',
|
error: 'GitHub authentication required or token expired',
|
||||||
authError: true
|
authError: true,
|
||||||
|
auth_url: authUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,9 +284,19 @@ class GitHubIntegrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Analyze codebase structure
|
// Analyze codebase structure
|
||||||
async analyzeCodebase(owner, repo, branch) {
|
async analyzeCodebase(owner, repo, branch, isPublicRepo = false) {
|
||||||
try {
|
try {
|
||||||
const octokit = await this.getAuthenticatedOctokit();
|
// Use appropriate octokit instance based on repository type
|
||||||
|
let octokit;
|
||||||
|
if (isPublicRepo) {
|
||||||
|
// For public repos, use unauthenticated octokit
|
||||||
|
octokit = new Octokit({
|
||||||
|
userAgent: 'CodeNuk-GitIntegration/1.0.0',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For private repos, use authenticated octokit
|
||||||
|
octokit = await this.getAuthenticatedOctokit();
|
||||||
|
}
|
||||||
|
|
||||||
// Get the commit SHA for the branch
|
// Get the commit SHA for the branch
|
||||||
const { data: ref } = await octokit.git.getRef({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
@ -394,7 +439,7 @@ class GitHubIntegrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Git-based: clone or update local repo and re-index into DB
|
// Git-based: clone or update local repo and re-index into DB
|
||||||
async syncRepositoryWithGit(owner, repo, branch, repositoryId) {
|
async syncRepositoryWithGit(owner, repo, branch, repositoryId, isPublicRepo = false) {
|
||||||
const database = require('../config/database');
|
const database = require('../config/database');
|
||||||
const localPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch);
|
const localPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch);
|
||||||
let storageRecord = null;
|
let storageRecord = null;
|
||||||
@ -408,8 +453,16 @@ class GitHubIntegrationService {
|
|||||||
localPath
|
localPath
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clone if missing (prefer authenticated HTTPS with OAuth token), otherwise fetch & fast-forward
|
// Clone if missing (prefer authenticated HTTPS with OAuth token for private repos, public for public repos)
|
||||||
let repoPath = null;
|
let repoPath = null;
|
||||||
|
|
||||||
|
if (isPublicRepo) {
|
||||||
|
// For public repos, try unauthenticated clone first
|
||||||
|
try {
|
||||||
|
repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to clone public repo without auth: ${error.message}`);
|
||||||
|
// Fallback to authenticated clone if available
|
||||||
try {
|
try {
|
||||||
const tokenRecord = await this.oauthService.getToken();
|
const tokenRecord = await this.oauthService.getToken();
|
||||||
if (tokenRecord?.access_token) {
|
if (tokenRecord?.access_token) {
|
||||||
@ -423,9 +476,33 @@ class GitHubIntegrationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For private repos, try authenticated clone first
|
||||||
|
try {
|
||||||
|
const tokenRecord = await this.oauthService.getToken();
|
||||||
|
if (tokenRecord?.access_token) {
|
||||||
|
repoPath = await this.gitRepoService.cloneIfMissingWithAuth(
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
'github.com',
|
||||||
|
tokenRecord.access_token,
|
||||||
|
'oauth2'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Fallback to unauthenticated clone (will likely fail for private repos)
|
||||||
if (!repoPath) {
|
if (!repoPath) {
|
||||||
repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch);
|
repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repoPath) {
|
||||||
|
throw new Error('Failed to clone repository');
|
||||||
|
}
|
||||||
|
|
||||||
const beforeSha = await this.gitRepoService.getHeadSha(repoPath);
|
const beforeSha = await this.gitRepoService.getHeadSha(repoPath);
|
||||||
const { afterSha } = await this.gitRepoService.fetchAndFastForward(repoPath, branch);
|
const { afterSha } = await this.gitRepoService.fetchAndFastForward(repoPath, branch);
|
||||||
|
|
||||||
@ -563,15 +640,15 @@ class GitHubIntegrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try git-based sync first, fall back to GitHub API download on failure
|
// Try git-based sync first, fall back to GitHub API download on failure
|
||||||
async syncRepositoryWithFallback(owner, repo, branch, repositoryId) {
|
async syncRepositoryWithFallback(owner, repo, branch, repositoryId, isPublicRepo = false) {
|
||||||
// First attempt: full git clone/fetch and index
|
// First attempt: full git clone/fetch and index
|
||||||
const gitResult = await this.syncRepositoryWithGit(owner, repo, branch, repositoryId);
|
const gitResult = await this.syncRepositoryWithGit(owner, repo, branch, repositoryId, isPublicRepo);
|
||||||
if (gitResult && gitResult.success) {
|
if (gitResult && gitResult.success) {
|
||||||
return { method: 'git', ...gitResult };
|
return { method: 'git', ...gitResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: API-based download and storage
|
// Fallback: API-based download and storage
|
||||||
const apiResult = await this.downloadRepositoryWithStorage(owner, repo, branch, repositoryId);
|
const apiResult = await this.downloadRepositoryWithStorage(owner, repo, branch, repositoryId, isPublicRepo);
|
||||||
if (apiResult && apiResult.success) {
|
if (apiResult && apiResult.success) {
|
||||||
return { method: 'api', ...apiResult, git_error: gitResult?.error };
|
return { method: 'api', ...apiResult, git_error: gitResult?.error };
|
||||||
}
|
}
|
||||||
@ -580,7 +657,7 @@ class GitHubIntegrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download repository files locally and store in database
|
// Download repository files locally and store in database
|
||||||
async downloadRepositoryWithStorage(owner, repo, branch, repositoryId) {
|
async downloadRepositoryWithStorage(owner, repo, branch, repositoryId, isPublicRepo = false) {
|
||||||
const targetDir = path.join(
|
const targetDir = path.join(
|
||||||
process.env.ATTACHED_REPOS_DIR,
|
process.env.ATTACHED_REPOS_DIR,
|
||||||
`${owner}__${repo}__${branch}`
|
`${owner}__${repo}__${branch}`
|
||||||
@ -600,7 +677,17 @@ class GitHubIntegrationService {
|
|||||||
targetDir
|
targetDir
|
||||||
);
|
);
|
||||||
|
|
||||||
const octokit = await this.getAuthenticatedOctokit();
|
// Use appropriate octokit instance based on repository type
|
||||||
|
let octokit;
|
||||||
|
if (isPublicRepo) {
|
||||||
|
// For public repos, use unauthenticated octokit
|
||||||
|
octokit = new Octokit({
|
||||||
|
userAgent: 'CodeNuk-GitIntegration/1.0.0',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For private repos, use authenticated octokit
|
||||||
|
octokit = await this.getAuthenticatedOctokit();
|
||||||
|
}
|
||||||
|
|
||||||
// Get the commit SHA for the branch
|
// Get the commit SHA for the branch
|
||||||
const { data: ref } = await octokit.git.getRef({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
|
|||||||
@ -40,6 +40,12 @@ class GitHubOAuthService {
|
|||||||
return `https://github.com/login/oauth/authorize?${params.toString()}`;
|
return `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate auth URL for a specific user (wrapper method)
|
||||||
|
async generateAuthUrl(userId) {
|
||||||
|
const state = Math.random().toString(36).substring(7);
|
||||||
|
return this.getAuthUrl(state, userId);
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange authorization code for access token
|
// Exchange authorization code for access token
|
||||||
async exchangeCodeForToken(code) {
|
async exchangeCodeForToken(code) {
|
||||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user