diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index dba49066..b27845d9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -51,7 +51,7 @@ function isProduction(): boolean { PromptToolModule, MailModule, TypeOrmModule.forFeature([User]), - GitHubModule + GitHubModule, ], providers: [ AppResolver, diff --git a/backend/src/github/github.controller.ts b/backend/src/github/github.controller.ts index 6fca640d..dcaec87b 100644 --- a/backend/src/github/github.controller.ts +++ b/backend/src/github/github.controller.ts @@ -11,7 +11,10 @@ import { UserService } from 'src/user/user.service'; export class GitHuController { private readonly webhookMiddleware; - constructor(private readonly gitHubAppService: GitHubAppService, private readonly userService: UserService) { + constructor( + private readonly gitHubAppService: GitHubAppService, + private readonly userService: UserService, + ) { // Get the App instance from the service const app = this.gitHubAppService.getApp(); @@ -24,7 +27,7 @@ export class GitHuController { @Post('webhook') async handleWebhook(@Req() req: Request, @Res() res: Response) { console.log('📩 Received POST /github/webhook'); - + return this.webhookMiddleware(req, res, (error?: any) => { if (error) { console.error('Webhook middleware error:', error); @@ -35,13 +38,17 @@ export class GitHuController { } }); } - + @Post('storeInstallation') async storeInstallation( - @Body() body: { installationId: string, githubCode: string }, + @Body() body: { installationId: string; githubCode: string }, @GetUserIdFromToken() userId: string, ) { - await this.userService.bindUserIdAndInstallId(userId, body.installationId, body.githubCode); + await this.userService.bindUserIdAndInstallId( + userId, + body.installationId, + body.githubCode, + ); return { success: true }; } } diff --git a/backend/src/github/github.module.ts b/backend/src/github/github.module.ts index c924e5c8..ad21a3a3 100644 --- a/backend/src/github/github.module.ts +++ b/backend/src/github/github.module.ts @@ -26,7 +26,14 @@ import { UserModule } from 'src/user/user.module'; forwardRef(() => UserModule), ], controllers: [GitHuController], - providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService], + providers: [ + ProjectService, + ProjectGuard, + GitHubAppService, + GitHubService, + ConfigService, + ChatService, + ], exports: [GitHubService], }) export class GitHubModule {} diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts index f3483dd3..24fd67d6 100644 --- a/backend/src/github/github.service.ts +++ b/backend/src/github/github.service.ts @@ -13,23 +13,26 @@ import { Repository } from 'typeorm'; @Injectable() export class GitHubService { private readonly logger = new Logger(GitHubService.name); - + private readonly appId: string; private privateKey: string; private ignored = ['node_modules', '.git', '.gitignore', '.env']; constructor( - private configService: ConfigService, + private configService: ConfigService, @InjectRepository(Project) - private projectsRepository: Repository,) - { - + private projectsRepository: Repository, + ) { this.appId = this.configService.get('GITHUB_APP_ID'); - - const privateKeyPath = this.configService.get('GITHUB_PRIVATE_KEY_PATH'); + + const privateKeyPath = this.configService.get( + 'GITHUB_PRIVATE_KEY_PATH', + ); if (!privateKeyPath) { - throw new Error('GITHUB_PRIVATE_KEY_PATH is not set in environment variables'); + throw new Error( + 'GITHUB_PRIVATE_KEY_PATH is not set in environment variables', + ); } this.logger.log(`Reading GitHub private key from: ${privateKeyPath}`); @@ -50,9 +53,9 @@ export class GitHubService { // 1) Create a JWT (valid for ~10 minutes) const now = Math.floor(Date.now() / 1000); const payload = { - iat: now, // Issued at time - exp: now + 600, // JWT expiration (10 minute maximum) - iss: this.appId, // Your GitHub App's App ID + iat: now, // Issued at time + exp: now + 600, // JWT expiration (10 minute maximum) + iss: this.appId, // Your GitHub App's App ID }; const gitHubAppJwt = jwt.sign(payload, this.privateKey, { @@ -77,13 +80,16 @@ export class GitHubService { return token; } - async exchangeOAuthCodeForToken(code: string): Promise { const clientId = this.configService.get('GITHUB_CLIENT_ID'); const clientSecret = this.configService.get('GITHUB_CLIENT_SECRET'); - - console.log('Exchanging OAuth Code:', { code, clientId, clientSecretExists: !!clientSecret }); - + + console.log('Exchanging OAuth Code:', { + code, + clientId, + clientSecretExists: !!clientSecret, + }); + try { const response = await axios.post( 'https://github.com/login/oauth/access_token', @@ -98,30 +104,37 @@ export class GitHubService { }, }, ); - + console.log('GitHub Token Exchange Response:', response.data); - + if (response.data.error) { console.error('GitHub OAuth error:', response.data); - throw new BadRequestException(`GitHub OAuth error: ${response.data.error_description}`); + throw new BadRequestException( + `GitHub OAuth error: ${response.data.error_description}`, + ); } - + const accessToken = response.data.access_token; if (!accessToken) { - throw new Error('GitHub token exchange failed: No access token returned.'); + throw new Error( + 'GitHub token exchange failed: No access token returned.', + ); } - + return accessToken; } catch (error: any) { - console.error('OAuth exchange failed:', error.response?.data || error.message); + console.error( + 'OAuth exchange failed:', + error.response?.data || error.message, + ); // throw new Error(`GitHub OAuth exchange failed: ${error.response?.data?.error_description || error.message}`); } } - + /** * Create a new repository under the *user's* account. * If you need an org-level repo, use POST /orgs/{org}/repos. - */ + */ async createUserRepo( repoName: string, isPublic: boolean, @@ -151,12 +164,17 @@ export class GitHubService { const data = response.data; return { owner: data.owner.login, // e.g. "octocat" - repo: data.name, // e.g. "my-new-repo" - htmlUrl: data.html_url, // e.g. "https://github.com/octocat/my-new-repo" + repo: data.name, // e.g. "my-new-repo" + htmlUrl: data.html_url, // e.g. "https://github.com/octocat/my-new-repo" }; } - async pushMultipleFiles(installationToken: string, owner: string, repo: string, files: string[]) { + async pushMultipleFiles( + installationToken: string, + owner: string, + repo: string, + files: string[], + ) { for (const file of files) { const fileName = path.basename(file); await this.pushFileContent( @@ -165,88 +183,98 @@ export class GitHubService { repo, file, `myFolder/${fileName}`, - 'Initial commit of file ' + fileName + 'Initial commit of file ' + fileName, ); } } /** * Push a single file to the given path in the repo using GitHub Contents API. - * + * * @param relativePathInRepo e.g. "backend/index.js" or "frontend/package.json" */ - async pushFileContent( - installationToken: string, - owner: string, - repo: string, - localFilePath: string, - relativePathInRepo: string, - commitMessage: string, - ) { - const fileBuffer = fs.readFileSync(localFilePath); - const base64Content = fileBuffer.toString('base64'); - - const url = `https://api.github.com/repos/${owner}/${repo}/contents/${relativePathInRepo}`; - - await axios.put( - url, - { - message: commitMessage, - content: base64Content, - }, - { - headers: { - Authorization: `token ${installationToken}`, - Accept: 'application/vnd.github.v3+json', - }, + async pushFileContent( + installationToken: string, + owner: string, + repo: string, + localFilePath: string, + relativePathInRepo: string, + commitMessage: string, + ) { + const fileBuffer = fs.readFileSync(localFilePath); + const base64Content = fileBuffer.toString('base64'); + + const url = `https://api.github.com/repos/${owner}/${repo}/contents/${relativePathInRepo}`; + + await axios.put( + url, + { + message: commitMessage, + content: base64Content, + }, + { + headers: { + Authorization: `token ${installationToken}`, + Accept: 'application/vnd.github.v3+json', }, - ); - - this.logger.log(`Pushed file: ${relativePathInRepo} -> https://github.com/${owner}/${repo}`); - } - - /** - * Recursively push all files in a local folder to the repo. - * Skips .git, node_modules, etc. (configurable) - */ - async pushFolderContent( - installationToken: string, - owner: string, - repo: string, - folderPath: string, - basePathInRepo: string, // e.g. "" or "backend" - ) { - const entries = fs.readdirSync(folderPath, { withFileTypes: true }); - - for (const entry of entries) { - - // Skip unwanted files - if (this.ignored.includes(entry.name)) { - continue; - } + }, + ); + + this.logger.log( + `Pushed file: ${relativePathInRepo} -> https://github.com/${owner}/${repo}`, + ); + } - const entryPath = path.join(folderPath, entry.name); - if (entry.isDirectory()) { - // Skip unwanted directories - if (entry.name === '.git' || entry.name === 'node_modules') { - continue; - } - // Recurse into subdirectory - const subDirInRepo = path.join(basePathInRepo, entry.name).replace(/\\/g, '/'); - await this.pushFolderContent(installationToken, owner, repo, entryPath, subDirInRepo); - } else { - // It's a file; push it - const fileInRepo = path.join(basePathInRepo, entry.name).replace(/\\/g, '/'); - await this.pushFileContent( - installationToken, - owner, - repo, - entryPath, - fileInRepo, - `Add file: ${fileInRepo}`, - ); + /** + * Recursively push all files in a local folder to the repo. + * Skips .git, node_modules, etc. (configurable) + */ + async pushFolderContent( + installationToken: string, + owner: string, + repo: string, + folderPath: string, + basePathInRepo: string, // e.g. "" or "backend" + ) { + const entries = fs.readdirSync(folderPath, { withFileTypes: true }); + + for (const entry of entries) { + // Skip unwanted files + if (this.ignored.includes(entry.name)) { + continue; + } + + const entryPath = path.join(folderPath, entry.name); + if (entry.isDirectory()) { + // Skip unwanted directories + if (entry.name === '.git' || entry.name === 'node_modules') { + continue; } + // Recurse into subdirectory + const subDirInRepo = path + .join(basePathInRepo, entry.name) + .replace(/\\/g, '/'); + await this.pushFolderContent( + installationToken, + owner, + repo, + entryPath, + subDirInRepo, + ); + } else { + // It's a file; push it + const fileInRepo = path + .join(basePathInRepo, entry.name) + .replace(/\\/g, '/'); + await this.pushFileContent( + installationToken, + owner, + repo, + entryPath, + fileInRepo, + `Add file: ${fileInRepo}`, + ); } } - + } } diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts index 9e922461..3e057533 100644 --- a/backend/src/github/githubApp.service.ts +++ b/backend/src/github/githubApp.service.ts @@ -1,7 +1,7 @@ // src/github/github-app.service.ts -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { App, Octokit} from 'octokit'; +import { Injectable, Logger } from '@nestjs/common'; +import { App, Octokit } from 'octokit'; import { readFileSync } from 'fs'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; @@ -19,7 +19,7 @@ export class GitHubAppService { //smee -u https://smee.io/asdasd -t http://127.0.0.1:8080/github/webhook constructor( - private configService: ConfigService, + private configService: ConfigService, @InjectRepository(User) private readonly userRepo: Repository, @InjectRepository(Project) @@ -29,7 +29,8 @@ export class GitHubAppService { const appId = this.configService.get('GITHUB_APP_ID'); const privateKeyPath = this.configService.get('GITHUB_PRIVATE_KEY_PATH'); const secret = this.configService.get('GITHUB_WEBHOOK_SECRET'); - const enterpriseHostname = this.configService.get('enterpriseHostname') || ""; + const enterpriseHostname = + this.configService.get('enterpriseHostname') || ''; // Read the private key from file const privateKey = readFileSync(privateKeyPath, 'utf8'); @@ -62,20 +63,22 @@ export class GitHubAppService { .catch((err) => { this.logger.error('Error fetching app info', err); }); - + // Handle when github remove this.app.webhooks.on('installation.deleted', async ({ payload }) => { - this.logger.log(`Received 'installation.deleted' event: installationId=${payload.installation.id}`); + this.logger.log( + `Received 'installation.deleted' event: installationId=${payload.installation.id}`, + ); const installationId = payload.installation.id.toString(); - this.logger.log(`uninstall Created: installationId=${installationId}, GitHub Login=`); + this.logger.log( + `uninstall Created: installationId=${installationId}, GitHub Login=`, + ); - // remove user github code and installationId + // remove user github code and installationId await this.userRepo.update( - { githubInstallationId: installationId }, - { githubInstallationId: null, - githubAccessToken: null - } + { githubInstallationId: installationId }, + { githubInstallationId: null, githubAccessToken: null }, ); this.logger.log(`Cleared installationId for user: ${installationId}`); @@ -83,20 +86,22 @@ export class GitHubAppService { // Handle when github repo removed this.app.webhooks.on('installation_repositories', async ({ payload }) => { - this.logger.log(`Received 'installation_repositories' event: installationId=${payload.installation.id}`); - + this.logger.log( + `Received 'installation_repositories' event: installationId=${payload.installation.id}`, + ); + const removedRepos = payload.repositories_removed; if (!removedRepos || removedRepos.length === 0) { this.logger.log('No repositories removed.'); return; } - + for (const repo of removedRepos) { const repoName = repo.name; const repoOwner = payload.installation.account.name; - + this.logger.log(`Removing repo: ${repoOwner}/${repoName}`); - + // Find project with matching githubRepoName and githubOwner const project = await this.projectRepo.findOne({ where: { @@ -104,36 +109,41 @@ export class GitHubAppService { githubOwner: repoOwner, }, }); - + if (!project) { - this.logger.warn(`Project not found for repo ${repoOwner}/${repoName}`); + this.logger.warn( + `Project not found for repo ${repoOwner}/${repoName}`, + ); continue; } - + // Update the project: clear sync data project.isSyncedWithGitHub = false; project.githubRepoName = null; project.githubRepoUrl = null; project.githubOwner = null; - + await this.projectRepo.save(project); this.logger.log(`Cleared GitHub sync info for project: ${project.id}`); } }); - // Handle errors this.app.webhooks.onError((error) => { if (error.name === 'AggregateError') { - this.logger.error(`Webhook signature verification failed: ${error.event}`); + this.logger.error( + `Webhook signature verification failed: ${error.event}`, + ); } else { this.logger.error(error); } }); - // only for webhooks debugging + // only for webhooks debugging this.app.webhooks.onAny(async (event) => { - this.logger.log(`onAny: Received event='${event.name}' action='${event.payload}'`); + this.logger.log( + `onAny: Received event='${event.name}' action='${event.payload}'`, + ); }); } diff --git a/backend/src/project/downloadController.ts b/backend/src/project/downloadController.ts index 535adb0e..46374f40 100644 --- a/backend/src/project/downloadController.ts +++ b/backend/src/project/downloadController.ts @@ -1,14 +1,12 @@ -import { Controller, Get, Logger, Param, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, Logger, Param, Res } from '@nestjs/common'; import { ProjectService } from 'src/project/project.service'; -import { ProjectGuard } from 'src/guard/project.guard'; import { GetUserIdFromToken } from 'src/decorator/get-auth-token.decorator'; import { Response } from 'express'; import * as fs from 'fs'; -import * as path from 'path'; @Controller('download') export class DownloadController { -private readonly logger = new Logger('DownloadController'); + private readonly logger = new Logger('DownloadController'); constructor(private readonly projectService: ProjectService) {} @Get('project/:projectId') @@ -18,20 +16,20 @@ private readonly logger = new Logger('DownloadController'); @Res() response: Response, ) { this.logger.log(`User ${userId} downloading project ${projectId}`); - + const { zipPath, fileName } = await this.projectService.createProjectZip( userId, projectId, ); - + response.set({ 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${fileName}"`, }); - + const fileStream = fs.createReadStream(zipPath); fileStream.pipe(response); - + fileStream.on('end', () => { fs.unlink(zipPath, (err) => { if (err) { diff --git a/backend/src/project/dto/project.input.ts b/backend/src/project/dto/project.input.ts index d013c60c..4dc9c0be 100644 --- a/backend/src/project/dto/project.input.ts +++ b/backend/src/project/dto/project.input.ts @@ -1,6 +1,12 @@ // DTOs for Project APIs import { InputType, Field, ID, ObjectType } from '@nestjs/graphql'; -import { IsNotEmpty, IsString, IsUUID, IsOptional, IsBoolean } from 'class-validator'; +import { + IsNotEmpty, + IsString, + IsUUID, + IsOptional, + IsBoolean, +} from 'class-validator'; import { Project } from '../project.model'; import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal'; diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 518ac0c9..b9200e3f 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -23,7 +23,14 @@ import { UserService } from 'src/user/user.service'; UploadModule, ], controllers: [DownloadController], - providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard, GitHubService, UserService], + providers: [ + ChatService, + ProjectService, + ProjectsResolver, + ProjectGuard, + GitHubService, + UserService, + ], exports: [ProjectService, ProjectGuard], }) export class ProjectModule {} diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index 480ac887..c1e899cb 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -166,8 +166,15 @@ export class ProjectsResolver { } @Mutation(() => Project) - async syncProjectToGitHub(@Args('projectId') projectId: string, @GetUserIdFromToken() userId: string,) { + async syncProjectToGitHub( + @Args('projectId') projectId: string, + @GetUserIdFromToken() userId: string, + ) { // TODO: MAKE PUBLIC DYNAMIC - return this.projectService.syncProjectToGitHub(userId, projectId, true /* isPublic? */); + return this.projectService.syncProjectToGitHub( + userId, + projectId, + true /* isPublic? */, + ); } } diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index 6cbb1cc9..216fb495 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -706,77 +706,82 @@ export class ProjectService { userId: string, projectId: string, ): Promise<{ zipPath: string; fileName: string }> { - // Get the project const project = await this.getProjectById(projectId); - + // Check ownership or if project is public if (project.userId !== userId && !project.isPublic) { throw new ForbiddenException( 'You do not have permission to download this project', ); } - + // Ensure the project path exists const projectPath = getProjectPath(project.projectPath); this.logger.debug(`Project path: ${projectPath}`); - + if (!fs.existsSync(projectPath)) { throw new NotFoundException( `Project directory not found at ${projectPath}`, ); } - + // Create a temporary directory for the zip file if it doesn't exist const tempDir = getTempDir(); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } - + // Generate a filename for the zip const fileName = `${project.projectName.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}.zip`; const zipPath = path.join(tempDir, fileName); - + // Create a write stream for the zip file const output = fs.createWriteStream(zipPath); const archive = archiver('zip', { zlib: { level: 9 }, // Set the compression level }); - + // Listen for errors output.on('error', (err) => { throw new InternalServerErrorException( `Error creating zip file: ${err.message}`, ); }); - + // Pipe the archive to the output file archive.pipe(output); // Filter unwanted files/folders const ignored = ['node_modules', '.git', '.gitignore', '.env']; - + // Add the project directory to the archive - archive.glob('**/*', { - cwd: projectPath, - ignore: ignored.map(pattern => `**/${pattern}/**`).concat(ignored), - dot: true - }, {}); - + archive.glob( + '**/*', + { + cwd: projectPath, + ignore: ignored.map((pattern) => `**/${pattern}/**`).concat(ignored), + dot: true, + }, + {}, + ); + // Finalize the archive await archive.finalize(); - + // Wait for the output stream to finish await new Promise((resolve, reject) => { output.on('close', () => { - this.logger.debug(`Created zip file: ${zipPath}, size: ${archive.pointer()} bytes`); + this.logger.debug( + `Created zip file: ${zipPath}, size: ${archive.pointer()} bytes`, + ); resolve(); }); output.on('error', (err) => { reject(err); }); }); - + return { zipPath, fileName }; } @@ -788,18 +793,21 @@ export class ProjectService { async syncProjectToGitHub( userId: string, projectId: string, - isPublic: boolean, // the user decides if the new repo is public or private + isPublic: boolean, // the user decides if the new repo is public or private ): Promise { - const user = await this.userService.getUser(userId); - + // 1) Find the project - const project = await this.projectsRepository.findOne({ where: { id: projectId } }); + const project = await this.projectsRepository.findOne({ + where: { id: projectId }, + }); if (!project) { throw new Error('Project not found'); } - - this.logger.log("check if the github project exist: " + project.isSyncedWithGitHub); + + this.logger.log( + 'check if the github project exist: ' + project.isSyncedWithGitHub, + ); // 2) Check user’s GitHub installation if (!user.githubInstallationId) { throw new Error('GitHub App not installed for this user'); @@ -816,15 +824,15 @@ export class ProjectService { // Use project.projectName or generate a safe name // TODO: WHEN REPO NAME EXIST - const repoName = project.projectName - .replace(/\s+/g, '-') - .toLowerCase() // e.g. "my-project" - + '-' + "ChangeME"; // to make it unique if needed + const repoName = + project.projectName.replace(/\s+/g, '-').toLowerCase() + // e.g. "my-project" + '-' + + 'ChangeME'; // to make it unique if needed const { owner, repo, htmlUrl } = await this.gitHubService.createUserRepo( repoName, isPublic, - userOAuthToken + userOAuthToken, ); project.githubRepoName = repo; diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 392c964e..6257e652 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { User } from './user.model'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; @@ -63,24 +67,33 @@ export class UserService { return this.userRepository.save(user); } - async bindUserIdAndInstallId(userId: string, installationId: string, githubCode: string): Promise { + async bindUserIdAndInstallId( + userId: string, + installationId: string, + githubCode: string, + ): Promise { const user = await this.userRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('User not found'); } if (user.githubInstallationId) { - throw new BadRequestException('User already linked to a GitHub installation.'); + throw new BadRequestException( + 'User already linked to a GitHub installation.', + ); } if (!githubCode) { throw new BadRequestException('Missing GitHub OAuth code'); } - console.log(`Binding GitHub installation ID ${installationId} to user code ${githubCode}`); + console.log( + `Binding GitHub installation ID ${installationId} to user code ${githubCode}`, + ); //First request to GitHub to exchange the code for an access token (Wont expire) - const accessToken = await this.gitHubService.exchangeOAuthCodeForToken(githubCode); + const accessToken = + await this.gitHubService.exchangeOAuthCodeForToken(githubCode); user.githubInstallationId = installationId; user.githubAccessToken = accessToken; @@ -90,7 +103,7 @@ export class UserService { } catch (error) { console.error('Error saving user:', error); throw new Error('Failed to save user with installation ID'); - } + } return true; } diff --git a/frontend/src/app/github/callback/page.tsx b/frontend/src/app/github/callback/page.tsx index 8bf050e7..c536b625 100644 --- a/frontend/src/app/github/callback/page.tsx +++ b/frontend/src/app/github/callback/page.tsx @@ -2,4 +2,4 @@ import GitHubCallback from '@/components/github-callback'; export default function GitHubCallbackPage() { return ; -} \ No newline at end of file +} diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index 78fc8416..51be2f85 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -37,43 +37,48 @@ const ResponsiveToolbar = ({ const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const { token, user, refreshUserInfo } = useAuthContext(); - + // Poll for GitHub installation status when needed const [isPollingGitHub, setIsPollingGitHub] = useState(false); - + // Apollo mutations and queries - const [syncProject, { loading: isPublishingToGitHub }] = useMutation(SYNC_PROJECT_TO_GITHUB, { - onCompleted: (data) => { - - const syncResult = data.syncProjectToGitHub; - - toast.success('Successfully published to GitHub!'); - - // Offer to open the repo in a new tab - const repoUrl = syncResult.githubRepoUrl; - console.log('GitHub repo URL:', repoUrl); - if (repoUrl) { - const shouldOpen = window.confirm('Would you like to open the GitHub repository?'); - if (shouldOpen) { - window.open(repoUrl, '_blank'); + const [syncProject, { loading: isPublishingToGitHub }] = useMutation( + SYNC_PROJECT_TO_GITHUB, + { + onCompleted: (data) => { + const syncResult = data.syncProjectToGitHub; + + toast.success('Successfully published to GitHub!'); + + // Offer to open the repo in a new tab + const repoUrl = syncResult.githubRepoUrl; + console.log('GitHub repo URL:', repoUrl); + if (repoUrl) { + const shouldOpen = window.confirm( + 'Would you like to open the GitHub repository?' + ); + if (shouldOpen) { + window.open(repoUrl, '_blank'); + } } - } - }, - onError: (error) => { - logger.error('Error publishing to GitHub:', error); - toast.error(`Error publishing to GitHub: ${error.message}`); + }, + onError: (error) => { + logger.error('Error publishing to GitHub:', error); + toast.error(`Error publishing to GitHub: ${error.message}`); + }, } - }); - + ); + // Query to check if the project is already synced const { data: projectData } = useQuery(GET_PROJECT, { variables: { projectId }, skip: !projectId, fetchPolicy: 'cache-and-network', }); - + // Determine if GitHub sync is complete based on query data - const isGithubSyncComplete = projectData?.getProject?.isSyncedWithGitHub || false; + const isGithubSyncComplete = + projectData?.getProject?.isSyncedWithGitHub || false; const githubRepoUrl = projectData?.getProject?.githubRepoUrl || ''; @@ -111,14 +116,14 @@ const ResponsiveToolbar = ({ // Poll for GitHub installation completion useEffect(() => { let pollInterval: NodeJS.Timeout; - + if (isPollingGitHub) { pollInterval = setInterval(async () => { console.log('Polling backend for GitHub installation status...'); try { // Call to refresh user info (from backend) await refreshUserInfo(); - + // Check if user now has installation ID if (user?.githubInstallationId) { console.log('GitHub installation complete!'); @@ -131,7 +136,7 @@ const ResponsiveToolbar = ({ } }, 3000); // Poll every 3s } - + return () => { if (pollInterval) clearInterval(pollInterval); }; @@ -140,7 +145,7 @@ const ResponsiveToolbar = ({ const handlePublishToGitHub = async () => { // If already publishing, do nothing if (isPublishingToGitHub) return; - + // If the user hasn't installed the GitHub App yet if (!user?.githubInstallationId) { try { @@ -148,11 +153,11 @@ const ResponsiveToolbar = ({ const shouldInstall = window.confirm( 'You need to install the GitHub App to publish your project. Would you like to do this now?' ); - + if (shouldInstall) { // Start polling for installation completion setIsPollingGitHub(true); - + // This format ensures GitHub will prompt the user to choose where to install const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; window.open(installUrl, '_blank'); @@ -161,32 +166,36 @@ const ResponsiveToolbar = ({ } catch (error) { logger.error('Error opening GitHub installation:', error); setIsPollingGitHub(false); - toast.error('Error opening GitHub installation page. Please try again.'); + toast.error( + 'Error opening GitHub installation page. Please try again.' + ); return; } } - + // Ensure we have a project ID if (!projectId) { toast.error('Cannot publish: No project ID available'); return; } - + // If already synced and we have the URL, offer to open it if (isGithubSyncComplete && githubRepoUrl) { - const shouldOpen = window.confirm('This project is already published to GitHub. Would you like to open the repository?'); + const shouldOpen = window.confirm( + 'This project is already published to GitHub. Would you like to open the repository?' + ); if (shouldOpen) { window.open(projectData.getProject.githubRepoUrl, '_blank'); } return; } - + // Execute the mutation try { await syncProject({ variables: { - projectId - } + projectId, + }, }); } catch (error) { // Error is handled by the mutation's onError callback @@ -202,7 +211,8 @@ const ResponsiveToolbar = ({ // Create a hidden anchor element for download const a = document.createElement('a'); - const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; + const backendUrl = + process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; // Set the download URL with credentials included const downloadUrl = `${backendUrl}/download/project/${projectId}`; @@ -234,9 +244,10 @@ const ResponsiveToolbar = ({ const contentDisposition = response.headers.get('Content-Disposition'); const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const matches = filenameRegex.exec(contentDisposition || ''); - const filename = matches && matches[1] - ? matches[1].replace(/['"]/g, '') - : `project-${projectId}.zip`; + const filename = + matches && matches[1] + ? matches[1].replace(/['"]/g, '') + : `project-${projectId}.zip`; a.download = filename; @@ -352,9 +363,14 @@ const ResponsiveToolbar = ({ Download )} @@ -371,9 +387,9 @@ const ResponsiveToolbar = ({ - - @@ -160,4 +182,4 @@ export default function GitHubCallback() { ); -} \ No newline at end of file +}