From 453df856ba4eaaf57c2e50e04db86879a2e08b3a Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Tue, 18 Mar 2025 22:53:24 -0400 Subject: [PATCH 01/17] frontend support --- .../chat/code-engine/code-engine.tsx | 11 +++ .../chat/code-engine/responsive-toolbar.tsx | 97 ++++++++++++++++++- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 8ac6da93..328c42ba 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -39,6 +39,8 @@ export function CodeEngine({ const editorRef = useRef(null); const projectPathRef = useRef(null); + const [activeProjectId, setActiveProjectId] = useState(projectId); + // Poll for project if needed using chatId useEffect(() => { if (!curProject && chatId && !projectLoading) { @@ -48,6 +50,10 @@ export function CodeEngine({ const project = await pollChatProject(chatId); if (project) { setLocalProject(project); + + if (project.id) { + setActiveProjectId(project.id); + } } } catch (error) { logger.error('Failed to load project from chat:', error); @@ -59,6 +65,10 @@ export function CodeEngine({ loadProjectFromChat(); } else { setIsLoading(projectLoading); + // If we have a current project from context, use its ID + if (curProject?.id) { + setActiveProjectId(curProject.id); + } } }, [chatId, curProject, projectLoading, pollChatProject]); @@ -289,6 +299,7 @@ export function CodeEngine({ isLoading={showLoader} activeTab={activeTab} setActiveTab={setActiveTab} + projectId={activeProjectId} />
diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index 46161cac..3d1841a1 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -4,27 +4,33 @@ import { Button } from '@/components/ui/button'; import { Code as CodeIcon, Copy, + Download, Eye, GitFork, Share2, Terminal, } from 'lucide-react'; +import { useAuthContext } from '@/providers/AuthProvider'; interface ResponsiveToolbarProps { isLoading: boolean; activeTab: 'preview' | 'code' | 'console'; setActiveTab: (tab: 'preview' | 'code' | 'console') => void; + projectId?: string; } const ResponsiveToolbar = ({ isLoading, activeTab, setActiveTab, + projectId, }: ResponsiveToolbarProps) => { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(700); const [visibleTabs, setVisibleTabs] = useState(3); const [compactIcons, setCompactIcons] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const { token } = useAuthContext(); // Observe container width changes useEffect(() => { @@ -57,6 +63,70 @@ const ResponsiveToolbar = ({ } }, [containerWidth]); + const handleDownload = async () => { + // If projectId is available, initiate download + if (projectId && !isDownloading) { + setIsDownloading(true); + try { + // Create a hidden anchor element for download + const a = document.createElement('a'); + + // Set the download URL with credentials included + const downloadUrl = `http://localhost:8080/download/project/${projectId}`; + + + const headers = new Headers(); + if (token) { + headers.append('Authorization', `Bearer ${token}`); + } + + // Fetch with credentials to ensure auth is included + const response = await fetch(downloadUrl, { + method: 'GET', + headers: headers, + }); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`); + } + + // Get the blob from the response + const blob = await response.blob(); + + // Create a URL for the blob + const url = window.URL.createObjectURL(blob); + + // Set the anchor's href to the blob URL + a.href = url; + + // Set download attribute with filename from Content-Disposition header or default + 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`; + + a.download = filename; + + // Append to the document + document.body.appendChild(a); + + // Click the anchor to start download + a.click(); + + // Clean up + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Error downloading project:', error); + // Could add a toast notification here + } finally { + setIsDownloading(false); + } + } + }; + return (
Publish + )} {compactIcons && ( - + <> + + + )}
@@ -150,4 +239,4 @@ const ResponsiveToolbar = ({ ); }; -export default ResponsiveToolbar; +export default ResponsiveToolbar; \ No newline at end of file From ce38d03d841af5023e08235e08efbff52673f2f2 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Tue, 18 Mar 2025 22:54:37 -0400 Subject: [PATCH 02/17] backend download support --- backend/src/project/downloadController.ts | 43 +++++++++++ backend/src/project/dto/project.input.ts | 14 +++- backend/src/project/project.module.ts | 2 + backend/src/project/project.service.ts | 89 +++++++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 backend/src/project/downloadController.ts diff --git a/backend/src/project/downloadController.ts b/backend/src/project/downloadController.ts new file mode 100644 index 00000000..535adb0e --- /dev/null +++ b/backend/src/project/downloadController.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Logger, Param, Req, Res, UseGuards } 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'); + constructor(private readonly projectService: ProjectService) {} + + @Get('project/:projectId') + async downloadProject( + @GetUserIdFromToken() userId: string, + @Param('projectId') projectId: string, + @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) { + this.logger.error(`Error deleting zip file: ${err.message}`); + } + }); + }); + } +} diff --git a/backend/src/project/dto/project.input.ts b/backend/src/project/dto/project.input.ts index f6715316..d013c60c 100644 --- a/backend/src/project/dto/project.input.ts +++ b/backend/src/project/dto/project.input.ts @@ -1,6 +1,6 @@ // DTOs for Project APIs import { InputType, Field, ID, ObjectType } from '@nestjs/graphql'; -import { IsNotEmpty, IsString, IsUUID, IsOptional } from 'class-validator'; +import { IsNotEmpty, IsString, IsUUID, IsOptional, IsBoolean } from 'class-validator'; import { Project } from '../project.model'; import { FileUpload, GraphQLUpload } from 'graphql-upload-minimal'; @@ -130,3 +130,15 @@ export class UpdateProjectPhotoInput { @Field(() => GraphQLUpload) file: FileUpload; } + +@InputType() +export class DownloadProjectInput { + @Field(() => ID) + @IsUUID() + projectId: string; + + @Field(() => Boolean, { defaultValue: false, nullable: true }) + @IsBoolean() + @IsOptional() + includeNodeModules?: boolean; +} diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index 9145f7d2..f02ae38f 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -11,6 +11,7 @@ import { User } from 'src/user/user.model'; import { Chat } from 'src/chat/chat.model'; import { AppConfigModule } from 'src/config/config.module'; import { UploadModule } from 'src/upload/upload.module'; +import { DownloadController } from './DownloadController'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { UploadModule } from 'src/upload/upload.module'; AppConfigModule, UploadModule, ], + controllers: [DownloadController], providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard], exports: [ProjectService, ProjectGuard], }) diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index 56eac43d..484b9513 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -31,6 +31,11 @@ import { PROJECT_DAILY_LIMIT, ProjectRateLimitException, } from './project-limits'; +import * as fs from 'fs'; +import * as path from 'path'; +import archiver from 'archiver'; +import { getProjectPath, getTempDir } from 'codefox-common'; + @Injectable() export class ProjectService { private readonly model: OpenAIModelProvider = @@ -685,4 +690,88 @@ export class ProjectService { return Math.max(0, PROJECT_DAILY_LIMIT - todayProjectCount); } + + /** + * Creates a ZIP file from a project's directory + * @param userId The user ID making the request + * @param projectId The project ID to download + * @returns The path to the created ZIP file and the suggested filename + */ + async createProjectZip( + 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 + }, {}); + + // 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`); + resolve(); + }); + output.on('error', (err) => { + reject(err); + }); + }); + + return { zipPath, fileName }; + } } From 6caf05bc5fc4b332c99fc6e62aa7451c06340476 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 20:15:22 -0400 Subject: [PATCH 03/17] backend github support --- backend/package.json | 5 + backend/src/app.module.ts | 2 + backend/src/github/github.module.ts | 31 +++ backend/src/github/github.service.ts | 227 ++++++++++++++++++ backend/src/github/githubApp.service.ts | 100 ++++++++ .../src/github/githubWebhook.controller.ts | 47 ++++ backend/src/main.ts | 3 +- backend/src/project/project.model.ts | 28 +++ backend/src/project/project.module.ts | 4 +- backend/src/project/project.resolver.ts | 5 + backend/src/project/project.service.ts | 70 ++++++ backend/src/user/user.model.ts | 11 + backend/src/user/user.service.ts | 21 +- 13 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 backend/src/github/github.module.ts create mode 100644 backend/src/github/github.service.ts create mode 100644 backend/src/github/githubApp.service.ts create mode 100644 backend/src/github/githubWebhook.controller.ts diff --git a/backend/package.json b/backend/package.json index aa09d147..78e663bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,10 +41,13 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.2", + "@octokit/auth-app": "^7.1.5", + "@octokit/webhooks": "^13.7.5", "@types/bcrypt": "^5.0.2", "@types/fs-extra": "^11.0.4", "@types/normalize-path": "^3.0.2", "@types/toposort": "^2.0.7", + "archiver": "^7.0.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", @@ -62,10 +65,12 @@ "markdown-to-txt": "^2.0.1", "nodemailer": "^6.10.0", "normalize-path": "^3.0.0", + "octokit": "^4.1.2", "openai": "^4.77.0", "p-queue-es5": "^6.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "simple-git": "^3.27.0", "sqlite3": "^5.1.7", "subscriptions-transport-ws": "^0.11.0", "toposort": "^2.0.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8a5c4a26..dba49066 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; import { LoggingInterceptor } from 'src/interceptor/LoggingInterceptor'; import { PromptToolModule } from './prompt-tool/prompt-tool.module'; import { MailModule } from './mail/mail.module'; +import { GitHubModule } from './github/github.module'; // TODO(Sma1lboy): move to a separate file function isProduction(): boolean { @@ -50,6 +51,7 @@ function isProduction(): boolean { PromptToolModule, MailModule, TypeOrmModule.forFeature([User]), + GitHubModule ], providers: [ AppResolver, diff --git a/backend/src/github/github.module.ts b/backend/src/github/github.module.ts new file mode 100644 index 00000000..448e8dc1 --- /dev/null +++ b/backend/src/github/github.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { ProjectGuard } from '../guard/project.guard'; +import { ChatService } from 'src/chat/chat.service'; +import { User } from 'src/user/user.model'; +import { Chat } from 'src/chat/chat.model'; +import { AppConfigModule } from 'src/config/config.module'; +import { UploadModule } from 'src/upload/upload.module'; +import { GitHubAppService } from './githubApp.service'; +import { GitHubService } from './github.service'; +import { Project } from 'src/project/project.model'; +import { ProjectPackages } from 'src/project/project-packages.model'; +import { GitHubWebhookController } from './githubWebhook.controller'; +import { ProjectService } from 'src/project/project.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { UserService } from 'src/user/user.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Project, Chat, User, ProjectPackages]), + AuthModule, + AppConfigModule, + UploadModule, + ConfigModule + ], + controllers: [GitHubWebhookController], + providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService, UserService], + exports: [GitHubService], +}) +export class GitHubModule {} diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts new file mode 100644 index 00000000..0ad27923 --- /dev/null +++ b/backend/src/github/github.service.ts @@ -0,0 +1,227 @@ +// src/github/github.service.ts + +import { Injectable, Logger } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class GitHubService { + private readonly logger = new Logger(GitHubService.name); + + private readonly appId: string; + private privateKey: string; + + constructor(private configService: ConfigService) { + this.appId = this.configService.get('GITHUB_APP_ID'); + + const privateKeyPath = this.configService.get('GITHUB_PRIVATE_KEY_PATH'); + + if (!privateKeyPath) { + throw new Error('GITHUB_PRIVATE_KEY_PATH is not set in environment variables'); + } + + this.logger.log(`Reading GitHub private key from: ${privateKeyPath}`); + + this.privateKey = fs.readFileSync(privateKeyPath, 'utf8'); + + if (!this.privateKey) { + throw new Error('GitHub private key is missing!'); + } + } + + /** + * 1) Generate a JWT for your GitHub App using the private key. + * 2) Use that JWT to get an installation access token. + * This token allows you to act on behalf of a particular installation (user/org). + */ + async getInstallationToken(installationId: string): Promise { + // 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 + }; + + const gitHubAppJwt = jwt.sign(payload, this.privateKey, { + algorithm: 'RS256', + }); + + // 2) Exchange JWT for an installation token + const tokenUrl = `https://api.github.com/app/installations/${installationId}/access_tokens`; + + const response = await axios.post( + tokenUrl, + {}, + { + headers: { + Authorization: `Bearer ${gitHubAppJwt}`, + Accept: 'application/vnd.github.v3+json', + }, + }, + ); + + const token = response.data.token; + 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('code', code); + const response = await axios.post( + 'https://github.com/login/oauth/access_token', + { + client_id: clientId, + client_secret: clientSecret, + code: code, + }, + { + headers: { + Accept: 'application/json', + }, + }, + ); + + const accessToken = response.data.access_token; + if (!accessToken) { + console.log(response.data); + throw new Error('Failed to exchange OAuth code for token'); + } + + return accessToken; + } + + /** + * Create a new repository under the *user's* account. + * If you need an org-level repo, use POST /orgs/{org}/repos. + */ + async createUserRepo( + installationToken: string, + repoName: string, + isPublic: boolean, + githubCode: string, + ): Promise<{ + owner: string; + repo: string; + htmlUrl: string; + }> { + const url = `https://api.github.com/user/repos`; + + const userOAuthToken = await this.exchangeOAuthCodeForToken(githubCode); + + const response = await axios.post( + url, + { + name: repoName, + private: !isPublic, // false => public, true => private + }, + { + headers: { + Authorization: `token ${userOAuthToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }, + ); + // The response will have data about the new repo + 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" + }; + } + + async pushMultipleFiles(installationToken: string, owner: string, repo: string, files: string[]) { + for (const file of files) { + const fileName = path.basename(file); + await this.pushFileContent( + installationToken, + owner, + repo, + file, + `myFolder/${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', + }, + }, + ); + + 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) { + 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 new file mode 100644 index 00000000..a7ed0fa5 --- /dev/null +++ b/backend/src/github/githubApp.service.ts @@ -0,0 +1,100 @@ +// src/github/github-app.service.ts + +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { App, Octokit} from 'octokit'; +import { readFileSync } from 'fs'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { User } from 'src/user/user.model'; +import { Repository } from 'typeorm'; + +@Injectable() +export class GitHubAppService { + private readonly logger = new Logger(GitHubAppService.name); + + private readonly app: App; + //smee -u https://smee.io/asdasd -t http://127.0.0.1:8080/github/webhook + constructor( + private configService: ConfigService, + @InjectRepository(User) + private readonly userRepo: Repository, + ) { + // Load from environment or config + 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') || ""; + + // Read the private key from file + const privateKey = readFileSync(privateKeyPath, 'utf8'); + + // Instantiate the GitHub App + if (enterpriseHostname) { + // Use custom hostname ONLY if it's non-empty + this.app = new App({ + appId, + privateKey, + webhooks: { secret }, + Octokit: Octokit.defaults({ + baseUrl: `https://${enterpriseHostname}/api/v3`, + }), + }); + } else { + this.app = new App({ + appId, + privateKey, + webhooks: { secret }, + }); + } + + // Optional: see who you're authenticated as + this.app.octokit + .request('/app') + .then((res) => { + this.logger.log(`Authenticated as GitHub App: ${res.data.name}`); + }) + .catch((err) => { + this.logger.error('Error fetching app info', err); + }); + + this.app.webhooks.on('installation.deleted', async ({ payload }) => { + 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=`); + + await this.userRepo.update( + { githubInstallationId: installationId }, + { githubInstallationId: null, + githubCode: null + } + ); + this.logger.log(`Cleared installationId for user: ${installationId}`); + + + // e.g. clear the installation ID in your DB + }); + + // Handle errors + this.app.webhooks.onError((error) => { + if (error.name === 'AggregateError') { + this.logger.error(`Webhook signature verification failed: ${error.event}`); + } else { + this.logger.error(error); + } + }); + + this.app.webhooks.onAny(async (event) => { + this.logger.log(`🔥 onAny: Received event='${event.name}' action='${event.payload}'`); + }); + + + } + + /** + * Expose a getter so you can retrieve the underlying App instance if needed + */ + getApp(): App { + return this.app; + } +} diff --git a/backend/src/github/githubWebhook.controller.ts b/backend/src/github/githubWebhook.controller.ts new file mode 100644 index 00000000..827e9e94 --- /dev/null +++ b/backend/src/github/githubWebhook.controller.ts @@ -0,0 +1,47 @@ +// src/github/github-webhook.controller.ts + +import { Body, Controller, Post, Req, Res } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { createNodeMiddleware } from '@octokit/webhooks'; +import { GitHubAppService } from './githubApp.service'; +import { GetUserIdFromToken } from 'src/decorator/get-auth-token.decorator'; +import { UserService } from 'src/user/user.service'; + +@Controller('github') +export class GitHubWebhookController { + private readonly webhookMiddleware; + + constructor(private readonly gitHubAppService: GitHubAppService, private readonly userService: UserService) { + // Get the App instance from the service + const app = this.gitHubAppService.getApp(); + + // Create the Express-style middleware from @octokit/webhooks + this.webhookMiddleware = createNodeMiddleware(app.webhooks, { + path: '/github/webhook', + }); + } + + @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); + return res.status(500).send('Internal Server Error'); + } else { + console.log('✅ Middleware processed request'); + return res.sendStatus(200); + } + }); + } + + @Post('storeInstallation') + async storeInstallation( + @Body() body: { installationId: string, githubCode: string }, + @GetUserIdFromToken() userId: string, + ) { + await this.userService.bindUserIdAndInstallId(userId, body.installationId, body.githubCode); + return { success: true }; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index b084baaa..dfb1d222 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -8,7 +8,8 @@ import { graphqlUploadExpress } from 'graphql-upload-minimal'; async function bootstrap() { const logger = new Logger('Bootstrap'); dotenv.config(); - const app = await NestFactory.create(AppModule); + + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors({ origin: '*', diff --git a/backend/src/project/project.model.ts b/backend/src/project/project.model.ts index 948c7b0a..c6a27dff 100644 --- a/backend/src/project/project.model.ts +++ b/backend/src/project/project.model.ts @@ -90,6 +90,34 @@ export class Project extends SystemBaseModel { @Column({ nullable: true }) photoUrl: string; + /** + * The name of the repo in GitHub (e.g. "my-cool-project"). + */ + @Field({ nullable: true }) + @Column({ nullable: true }) + githubRepoName?: string; + + /** + * The GitHub HTML URL for this repo (e.g. "https://github.com/username/my-cool-project"). + */ + @Field({ nullable: true }) + @Column({ nullable: true }) + githubRepoUrl?: string; + + /** + * The GitHub username or organization name that owns the repo. + */ + @Field({ nullable: true }) + @Column({ nullable: true }) + githubOwner?: string; + + /** + * Whether this project has been synced/pushed to GitHub. + */ + @Field() + @Column({ default: false }) + isSyncedWithGitHub: boolean; + /** * Unique identifier for tracking project lineage * Used to track which projects are copies of others diff --git a/backend/src/project/project.module.ts b/backend/src/project/project.module.ts index f02ae38f..518ac0c9 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -12,6 +12,8 @@ import { Chat } from 'src/chat/chat.model'; import { AppConfigModule } from 'src/config/config.module'; import { UploadModule } from 'src/upload/upload.module'; import { DownloadController } from './DownloadController'; +import { GitHubService } from 'src/github/github.service'; +import { UserService } from 'src/user/user.service'; @Module({ imports: [ @@ -21,7 +23,7 @@ import { DownloadController } from './DownloadController'; UploadModule, ], controllers: [DownloadController], - providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard], + 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 44dbfcaf..90782a08 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -164,4 +164,9 @@ export class ProjectsResolver { ): Promise { return this.projectService.getRemainingProjectLimit(userId); } + + @Mutation(() => Project) + async syncProjectToGitHub(@Args('projectId') projectId: string, @GetUserIdFromToken() userId: string,) { + 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 484b9513..499cfc47 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -35,6 +35,8 @@ import * as fs from 'fs'; import * as path from 'path'; import archiver from 'archiver'; import { getProjectPath, getTempDir } from 'codefox-common'; +import { GitHubService } from 'src/github/github.service'; +import { UserService } from 'src/user/user.service'; @Injectable() export class ProjectService { @@ -51,6 +53,8 @@ export class ProjectService { private projectPackagesRepository: Repository, private chatService: ChatService, private uploadService: UploadService, + private readonly gitHubService: GitHubService, + private userService: UserService, ) {} async getProjectsByUser(userId: string): Promise { @@ -774,4 +778,70 @@ export class ProjectService { return { zipPath, fileName }; } + + /** + * Sync a project to GitHub: + * 1) Create a GitHub repo if needed. + * 2) Recursively push the entire local project folder to the new repo. + */ + async syncProjectToGitHub( + userId: string, + projectId: string, + 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 } }); + if (!project) { + throw new Error('Project not found'); + } + + // 2) Check user’s GitHub installation + if (!user.githubInstallationId) { + throw new Error('GitHub App not installed for this user'); + } + + // 3) Get the installation token + const installationToken = await this.gitHubService.getInstallationToken( + user.githubInstallationId, + ); + + // 4) Create the repo if the project doesn’t have it yet + if (!project.githubRepoName || !project.githubOwner) { + // Use project.projectName or generate a safe name + const repoName = project.projectName + .replace(/\s+/g, '-') + .toLowerCase() // e.g. "my-project" + + '-' + Date.now(); // to make it unique if needed + + const { owner, repo, htmlUrl } = await this.gitHubService.createUserRepo( + installationToken, + repoName, + isPublic, + user.githubCode + ); + + project.githubRepoName = repo; + project.githubRepoUrl = htmlUrl; + project.githubOwner = owner; + } + + // 5) Recursively push the entire local project folder + // If your projectPath is something like "/path/to/myProject", + // we'll just push everything inside it, ignoring .git, node_modules, etc. + const projectPath = getProjectPath(project.projectPath); + await this.gitHubService.pushFolderContent( + installationToken, + project.githubOwner, + project.githubRepoName, + projectPath, + '', // basePathInRepo (empty => push at repo root) + ); + + // 6) Mark as synced and update DB + project.isSyncedWithGitHub = true; + return this.projectsRepository.save(project); + } } diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 1655392e..8da87415 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -75,6 +75,17 @@ export class User extends SystemBaseModel { }) roles: Role[]; + /** + * The GitHub App installation ID for this user (if they have installed the app). + */ + @Field({ nullable: true }) + @Column({ nullable: true }) + githubInstallationId?: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + githubCode?: string; + /** * This field is maintained for API compatibility but is no longer actively used. * With the new design, a user's "subscribed projects" are just their own projects diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index e4476b2e..99e886c9 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { User } from './user.model'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; @@ -60,4 +60,23 @@ export class UserService { user.avatarUrl = result.url; return this.userRepository.save(user); } + + 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.'); + } + + console.log(`Binding GitHub installation ID ${installationId} to user ${githubCode}`); + user.githubInstallationId = installationId; + user.githubCode = githubCode; + + await this.userRepository.save(user); + + return true; + } } From f22482fd6055538d31f2725d13015af46efa786a Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 20:15:53 -0400 Subject: [PATCH 04/17] init github frontend --- frontend/src/app/github/callback/page.tsx | 5 + .../chat/code-engine/responsive-toolbar.tsx | 105 +++++++++++- frontend/src/components/github-callback.tsx | 157 ++++++++++++++++++ frontend/src/graphql/request.ts | 1 + frontend/src/graphql/schema.gql | 8 + frontend/src/graphql/type.tsx | 48 ++++++ 6 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/github/callback/page.tsx create mode 100644 frontend/src/components/github-callback.tsx diff --git a/frontend/src/app/github/callback/page.tsx b/frontend/src/app/github/callback/page.tsx new file mode 100644 index 00000000..8bf050e7 --- /dev/null +++ b/frontend/src/app/github/callback/page.tsx @@ -0,0 +1,5 @@ +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 3d1841a1..1aca19f4 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -7,6 +7,7 @@ import { Download, Eye, GitFork, + Github, Share2, Terminal, } from 'lucide-react'; @@ -30,7 +31,9 @@ const ResponsiveToolbar = ({ const [visibleTabs, setVisibleTabs] = useState(3); const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); - const { token } = useAuthContext(); + const { token, user } = useAuthContext(); + const [isPublishingToGitHub, setIsPublishingToGitHub] = useState(false); + const [isGithubSyncComplete, setIsGithubSyncComplete] = useState(false); // Observe container width changes useEffect(() => { @@ -63,6 +66,97 @@ const ResponsiveToolbar = ({ } }, [containerWidth]); + const handlePublishToGitHub = async () => { + // Check if GitHub App is installed + // Check if GitHub App is installed + if (!user?.githubInstallationId) { + // Prompt the user to install the GitHub App + const shouldInstall = window.confirm( + 'You need to install the GitHub App to publish your project. Would you like to do this now?' + ); + + if (shouldInstall) { + // This format ensures GitHub will prompt the user to choose where to install + // Replace APP_ID with your actual GitHub App ID + const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; + window.open(installUrl); + + // Optionally inform the user what to do after installation + alert('After installing the GitHub App, please return to this page and try publishing again.'); + } + return; + } + + // Ensure we have a project ID + if (!projectId) { + alert('Cannot publish: No project ID available'); + return; + } + + // Set loading state + setIsPublishingToGitHub(true); + + try { + // Call the syncProject mutation with GraphQL + const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + query: ` + mutation SyncProject($projectId: String!) { + syncProject(projectId: $projectId) { + id + projectName + isSyncedWithGitHub + githubOwner + githubRepoName + githubRepoUrl + } + } + + `, + variables: { + projectId + } + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const result = await response.json(); + + if (result.errors) { + throw new Error(result.errors[0].message || 'GraphQL error'); + } + + // Get the repo URL from the response + const repoUrl = result.data.syncProject.githubRepoUrl; + + // Success! + setIsGithubSyncComplete(true); + + alert('Successfully published to GitHub!'); + + // Open the repo in a new tab + if (repoUrl) { + const shouldOpen = window.confirm('Would you like to open the GitHub repository?'); + if (shouldOpen) { + window.open(repoUrl, '_blank'); + } + } + } catch (error) { + console.error('Error publishing to GitHub:', error); + alert(`Error publishing to GitHub: ${error.message || 'Unknown error'}`); + } finally { + setIsPublishingToGitHub(false); + } + }; + const handleDownload = async () => { // If projectId is available, initiate download if (projectId && !isDownloading) { @@ -216,6 +310,15 @@ const ResponsiveToolbar = ({ Download + )} {compactIcons && ( diff --git a/frontend/src/components/github-callback.tsx b/frontend/src/components/github-callback.tsx new file mode 100644 index 00000000..a86ee8b0 --- /dev/null +++ b/frontend/src/components/github-callback.tsx @@ -0,0 +1,157 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuthContext } from '@/providers/AuthProvider'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Github, Check, AlertCircle, Loader } from 'lucide-react'; + +// This component handles the GitHub App installation callback +export default function GitHubCallback() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { token } = useAuthContext(); + + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + // Extract installation ID from search params + const githubCode = searchParams.get('code'); + const installationId = searchParams.get('installation_id'); + const setupAction = searchParams.get('setup_action'); + + console.log('GitHub Callback:', { githubCode, installationId, setupAction }); + + // If there's no installation ID, this might be a cancellation + if (!installationId || !githubCode) { + // Check if it was canceled + if (searchParams.get('canceled') === 'true') { + setStatus('error'); + setErrorMessage('GitHub App installation was canceled.'); + return; + } + + // Or if it's a delete operation + if (setupAction === 'delete') { + setStatus('success'); + // We could call a different endpoint to remove the installationId + return; + } + + setStatus('error'); + setErrorMessage('No installation ID was provided.'); + return; + } + + // Call the backend directly to store the installation ID + const storeInstallation = async () => { + try { + // Use environment variable or hardcoded value for backend URL + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; + + const response = await fetch(`${backendUrl}/github/storeInstallation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ installationId, githubCode}) + }); + + if (!response.ok) { + throw new Error(`Failed to store installation: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + setStatus('success'); + } else { + throw new Error('Backend reported failure'); + } + } catch (error) { + console.error('Error storing GitHub installation:', error); + setStatus('error'); + setErrorMessage(error.message || 'Failed to store GitHub installation.'); + } + }; + + storeInstallation(); + }, [searchParams, token]); + + // Function to handle redirect back to projects + const handleContinue = () => { + router.push('/projects'); // Change this to your desired redirect path + }; + + return ( +
+ + +
+ + GitHub Integration +
+ + Completing your GitHub App installation + +
+ + + {status === 'loading' && ( + <> + +

+ Finalizing your GitHub App installation... +

+ + )} + + {status === 'success' && ( + <> +
+ +
+

Installation Complete!

+

+ Your GitHub App has been successfully installed. You can now publish your projects to GitHub. +

+ + )} + + {status === 'error' && ( + <> +
+ +
+

Installation Failed

+

+ We encountered an error while setting up your GitHub integration. +

+ {errorMessage && ( +

+ {errorMessage} +

+ )} + + )} +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/graphql/request.ts b/frontend/src/graphql/request.ts index e794e7dd..7503024c 100644 --- a/frontend/src/graphql/request.ts +++ b/frontend/src/graphql/request.ts @@ -102,6 +102,7 @@ export const GET_USER_INFO = gql` username email avatarUrl + githubInstallationId } } `; diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 6c419a01..4b7879c7 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -123,6 +123,7 @@ type Mutation { registerUser(input: RegisterUserInput!): User! resendConfirmationEmail(input: ResendEmailInput!): EmailConfirmationResponse! subscribeToProject(projectId: ID!): Project! + syncProjectToGitHub(projectId: String!): Project! triggerChatStream(input: ChatInputType!): Boolean! updateChatTitle(updateChatTitleInput: UpdateChatTitleInput!): Chat updateProjectPhoto(input: UpdateProjectPhotoInput!): Project! @@ -140,10 +141,14 @@ type Project { forkedFrom: Project forkedFromId: String forks: [Project!] + githubOwner: String + githubRepoName: String + githubRepoUrl: String id: ID! isActive: Boolean! isDeleted: Boolean! isPublic: Boolean! + isSyncedWithGitHub: Boolean! photoUrl: String projectName: String! projectPackages: [ProjectPackages!] @@ -198,6 +203,7 @@ type RefreshTokenResponse { } input RegisterUserInput { + confirmPassword: String! email: String! password: String! username: String! @@ -240,6 +246,8 @@ type User { chats: [Chat!]! createdAt: Date! email: String! + githubCode: String + githubInstallationId: String id: ID! isActive: Boolean! isDeleted: Boolean! diff --git a/frontend/src/graphql/type.tsx b/frontend/src/graphql/type.tsx index 3def174b..06e80bb3 100644 --- a/frontend/src/graphql/type.tsx +++ b/frontend/src/graphql/type.tsx @@ -168,6 +168,7 @@ export type Mutation = { registerUser: User; resendConfirmationEmail: EmailConfirmationResponse; subscribeToProject: Project; + syncProjectToGitHub: Project; triggerChatStream: Scalars['Boolean']['output']; updateChatTitle?: Maybe; updateProjectPhoto: Project; @@ -227,6 +228,10 @@ export type MutationSubscribeToProjectArgs = { projectId: Scalars['ID']['input']; }; +export type MutationSyncProjectToGitHubArgs = { + projectId: Scalars['String']['input']; +}; + export type MutationTriggerChatStreamArgs = { input: ChatInputType; }; @@ -259,10 +264,14 @@ export type Project = { forkedFrom?: Maybe; forkedFromId?: Maybe; forks?: Maybe>; + githubOwner?: Maybe; + githubRepoName?: Maybe; + githubRepoUrl?: Maybe; id: Scalars['ID']['output']; isActive: Scalars['Boolean']['output']; isDeleted: Scalars['Boolean']['output']; isPublic: Scalars['Boolean']['output']; + isSyncedWithGitHub: Scalars['Boolean']['output']; photoUrl?: Maybe; projectName: Scalars['String']['output']; projectPackages?: Maybe>; @@ -347,6 +356,7 @@ export type RefreshTokenResponse = { }; export type RegisterUserInput = { + confirmPassword: Scalars['String']['input']; email: Scalars['String']['input']; password: Scalars['String']['input']; username: Scalars['String']['input']; @@ -385,6 +395,8 @@ export type User = { chats: Array; createdAt: Scalars['Date']['output']; email: Scalars['String']['output']; + githubCode?: Maybe; + githubInstallationId?: Maybe; id: Scalars['ID']['output']; isActive: Scalars['Boolean']['output']; isDeleted: Scalars['Boolean']['output']; @@ -808,6 +820,12 @@ export type MutationResolvers< ContextType, RequireFields >; + syncProjectToGitHub?: Resolver< + ResolversTypes['Project'], + ParentType, + ContextType, + RequireFields + >; triggerChatStream?: Resolver< ResolversTypes['Boolean'], ParentType, @@ -865,10 +883,30 @@ export type ProjectResolvers< ParentType, ContextType >; + githubOwner?: Resolver< + Maybe, + ParentType, + ContextType + >; + githubRepoName?: Resolver< + Maybe, + ParentType, + ContextType + >; + githubRepoUrl?: Resolver< + Maybe, + ParentType, + ContextType + >; id?: Resolver; isActive?: Resolver; isDeleted?: Resolver; isPublic?: Resolver; + isSyncedWithGitHub?: Resolver< + ResolversTypes['Boolean'], + ParentType, + ContextType + >; photoUrl?: Resolver, ParentType, ContextType>; projectName?: Resolver; projectPackages?: Resolver< @@ -1025,6 +1063,16 @@ export type UserResolvers< chats?: Resolver, ParentType, ContextType>; createdAt?: Resolver; email?: Resolver; + githubCode?: Resolver< + Maybe, + ParentType, + ContextType + >; + githubInstallationId?: Resolver< + Maybe, + ParentType, + ContextType + >; id?: Resolver; isActive?: Resolver; isDeleted?: Resolver; From 4889caaccbe514125a09bdedef83495393c62233 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 20:51:13 -0400 Subject: [PATCH 05/17] update example env for github --- backend/.env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/.env.example b/backend/.env.example index c3c50c60..c42fb663 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,3 +32,11 @@ MAIL_FROM=noreply@example.com MAIL_PORT=587 MAIL_DOMAIN=your_net +# github app +GITHUB_APP_ENABLED=false +GITHUB_APP_ID="GITHUB_APP_ID" +GITHUB_PRIVATE_KEY_PATH="YOUR_PATH" +GITHUB_CLIENT_ID="GITHUB_CLIENT_ID" +GITHUB_CLIENT_SECRET="GITHUB_CLIENT_SECRET" +GITHUB_WEBHOOK_SECRET="GITHUB_WEBHOOK_SECRET" +CALLBACK="CALLBACK" From db8505531b99265ed9104e5beffe275ded60ed1b Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 20:51:24 -0400 Subject: [PATCH 06/17] update example --- backend/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index c42fb663..0b54bc76 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,7 +33,7 @@ MAIL_PORT=587 MAIL_DOMAIN=your_net # github app -GITHUB_APP_ENABLED=false +GITHUB_APP_ENABLED=true GITHUB_APP_ID="GITHUB_APP_ID" GITHUB_PRIVATE_KEY_PATH="YOUR_PATH" GITHUB_CLIENT_ID="GITHUB_CLIENT_ID" From e6f8a0febfb9ee282ab370fd9bb3288448bcc751 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 23:23:43 -0400 Subject: [PATCH 07/17] update now fully work --- ...ook.controller.ts => github.controller.ts} | 6 +- backend/src/github/github.module.ts | 4 +- backend/src/github/github.service.ts | 27 +- backend/src/github/githubApp.service.ts | 10 +- backend/src/project/project.service.ts | 10 +- .../chat/code-engine/responsive-toolbar.tsx | 241 +++++++++++------- frontend/src/graphql/request.ts | 24 ++ 7 files changed, 212 insertions(+), 110 deletions(-) rename backend/src/github/{githubWebhook.controller.ts => github.controller.ts} (90%) diff --git a/backend/src/github/githubWebhook.controller.ts b/backend/src/github/github.controller.ts similarity index 90% rename from backend/src/github/githubWebhook.controller.ts rename to backend/src/github/github.controller.ts index 827e9e94..6fca640d 100644 --- a/backend/src/github/githubWebhook.controller.ts +++ b/backend/src/github/github.controller.ts @@ -8,7 +8,7 @@ import { GetUserIdFromToken } from 'src/decorator/get-auth-token.decorator'; import { UserService } from 'src/user/user.service'; @Controller('github') -export class GitHubWebhookController { +export class GitHuController { private readonly webhookMiddleware; constructor(private readonly gitHubAppService: GitHubAppService, private readonly userService: UserService) { @@ -27,10 +27,10 @@ export class GitHubWebhookController { return this.webhookMiddleware(req, res, (error?: any) => { if (error) { - console.error('⚠️ Webhook middleware error:', error); + console.error('Webhook middleware error:', error); return res.status(500).send('Internal Server Error'); } else { - console.log('✅ Middleware processed request'); + console.log('Middleware processed request'); return res.sendStatus(200); } }); diff --git a/backend/src/github/github.module.ts b/backend/src/github/github.module.ts index 448e8dc1..ceb584a8 100644 --- a/backend/src/github/github.module.ts +++ b/backend/src/github/github.module.ts @@ -11,7 +11,7 @@ import { GitHubAppService } from './githubApp.service'; import { GitHubService } from './github.service'; import { Project } from 'src/project/project.model'; import { ProjectPackages } from 'src/project/project-packages.model'; -import { GitHubWebhookController } from './githubWebhook.controller'; +import { GitHuController } from './github.controller'; import { ProjectService } from 'src/project/project.service'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { UserService } from 'src/user/user.service'; @@ -24,7 +24,7 @@ import { UserService } from 'src/user/user.service'; UploadModule, ConfigModule ], - controllers: [GitHubWebhookController], + controllers: [GitHuController], providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService, UserService], exports: [GitHubService], }) diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts index 0ad27923..6be34077 100644 --- a/backend/src/github/github.service.ts +++ b/backend/src/github/github.service.ts @@ -6,15 +6,24 @@ import axios from 'axios'; import * as fs from 'fs'; import * as path from 'path'; import { ConfigService } from '@nestjs/config'; +import { Project } from 'src/project/project.model'; +import { InjectRepository } from '@nestjs/typeorm'; +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, + @InjectRepository(Project) + private projectsRepository: Repository,) + { - constructor(private configService: ConfigService) { this.appId = this.configService.get('GITHUB_APP_ID'); const privateKeyPath = this.configService.get('GITHUB_PRIVATE_KEY_PATH'); @@ -102,10 +111,9 @@ export class GitHubService { * If you need an org-level repo, use POST /orgs/{org}/repos. */ async createUserRepo( - installationToken: string, repoName: string, isPublic: boolean, - githubCode: string, + userOAuthToken: string, ): Promise<{ owner: string; repo: string; @@ -113,8 +121,6 @@ export class GitHubService { }> { const url = `https://api.github.com/user/repos`; - const userOAuthToken = await this.exchangeOAuthCodeForToken(githubCode); - const response = await axios.post( url, { @@ -128,6 +134,7 @@ export class GitHubService { }, }, ); + // The response will have data about the new repo const data = response.data; return { @@ -151,7 +158,7 @@ export class GitHubService { } } - /** + /** * 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" @@ -200,6 +207,12 @@ export class GitHubService { 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 diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts index a7ed0fa5..a0ad6757 100644 --- a/backend/src/github/githubApp.service.ts +++ b/backend/src/github/githubApp.service.ts @@ -63,16 +63,15 @@ export class GitHubAppService { this.logger.log(`uninstall Created: installationId=${installationId}, GitHub Login=`); + // remove user github code and installationId await this.userRepo.update( { githubInstallationId: installationId }, { githubInstallationId: null, githubCode: null } ); - this.logger.log(`Cleared installationId for user: ${installationId}`); - - // e.g. clear the installation ID in your DB + this.logger.log(`Cleared installationId for user: ${installationId}`); }); // Handle errors @@ -84,11 +83,10 @@ export class GitHubAppService { } }); + // 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/project.service.ts b/backend/src/project/project.service.ts index 499cfc47..bd0a2d48 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -808,19 +808,21 @@ export class ProjectService { user.githubInstallationId, ); + const githubCode = user.githubCode; + const userOAuthToken = await this.gitHubService.exchangeOAuthCodeForToken(githubCode); + // 4) Create the repo if the project doesn’t have it yet if (!project.githubRepoName || !project.githubOwner) { // Use project.projectName or generate a safe name const repoName = project.projectName .replace(/\s+/g, '-') .toLowerCase() // e.g. "my-project" - + '-' + Date.now(); // to make it unique if needed + + '-' + project.githubOwner; // to make it unique if needed const { owner, repo, htmlUrl } = await this.gitHubService.createUserRepo( - installationToken, repoName, isPublic, - user.githubCode + userOAuthToken ); project.githubRepoName = repo; @@ -832,6 +834,8 @@ export class ProjectService { // If your projectPath is something like "/path/to/myProject", // we'll just push everything inside it, ignoring .git, node_modules, etc. const projectPath = getProjectPath(project.projectPath); + + // delete await for now, To make it background running await this.gitHubService.pushFolderContent( installationToken, project.githubOwner, diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index 1aca19f4..bfc80322 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Code as CodeIcon, @@ -10,8 +10,13 @@ import { Github, Share2, Terminal, + Loader, } from 'lucide-react'; import { useAuthContext } from '@/providers/AuthProvider'; +import { logger } from '@/app/log/logger'; +import { useMutation, useQuery, gql } from '@apollo/client'; +import { toast } from 'sonner'; +import { SYNC_PROJECT_TO_GITHUB, GET_PROJECT } from '../../../graphql/request'; interface ResponsiveToolbarProps { isLoading: boolean; @@ -31,9 +36,41 @@ const ResponsiveToolbar = ({ const [visibleTabs, setVisibleTabs] = useState(3); const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); - const { token, user } = useAuthContext(); - const [isPublishingToGitHub, setIsPublishingToGitHub] = useState(false); - const [isGithubSyncComplete, setIsGithubSyncComplete] = useState(false); + const { token, user, refreshUserInfo } = useAuthContext(); + + // Poll for GitHub installation status when needed + const [isPollingGitHub, setIsPollingGitHub] = useState(false); + const [gitHubInstallationWindow, setGitHubInstallationWindow] = useState(null); + + // Apollo mutations and queries + const [syncProject, { loading: isPublishingToGitHub }] = useMutation(SYNC_PROJECT_TO_GITHUB, { + onCompleted: (data) => { + toast.success('Successfully published to GitHub!'); + + // Offer to open the repo in a new tab + const repoUrl = data.syncProject.githubRepoUrl; + 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}`); + } + }); + + // 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; // Observe container width changes useEffect(() => { @@ -66,94 +103,96 @@ const ResponsiveToolbar = ({ } }, [containerWidth]); + // Poll for GitHub installation completion + useEffect(() => { + let pollInterval: NodeJS.Timeout; + + if (isPollingGitHub) { + pollInterval = setInterval(async () => { + try { + // Check if the installation window is still open + if (gitHubInstallationWindow && gitHubInstallationWindow.closed) { + logger.info('GitHub installation window closed, checking installation status'); + + // Refresh user data to get updated installation status + await refreshUserInfo(); + + // Stop polling + setIsPollingGitHub(false); + setGitHubInstallationWindow(null); + } + } catch (error) { + logger.error('Error polling for GitHub installation status:', error); + setIsPollingGitHub(false); + } + }, 2000); // Poll every 2 seconds + } + + return () => { + if (pollInterval) clearInterval(pollInterval); + }; + }, [isPollingGitHub, gitHubInstallationWindow, refreshUserInfo]); + + // No need for a manual check function since we're using Apollo useQuery now + const handlePublishToGitHub = async () => { - // Check if GitHub App is installed - // Check if GitHub App is installed + // If already publishing, do nothing + if (isPublishingToGitHub) return; + + // If the user hasn't installed the GitHub App yet if (!user?.githubInstallationId) { - // Prompt the user to install the GitHub App - const shouldInstall = window.confirm( - 'You need to install the GitHub App to publish your project. Would you like to do this now?' - ); - - if (shouldInstall) { - // This format ensures GitHub will prompt the user to choose where to install - // Replace APP_ID with your actual GitHub App ID - const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; - window.open(installUrl); + try { + // Prompt the user to install the GitHub App + const shouldInstall = window.confirm( + 'You need to install the GitHub App to publish your project. Would you like to do this now?' + ); - // Optionally inform the user what to do after installation - alert('After installing the GitHub App, please return to this page and try publishing again.'); + 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`; + const installWindow = window.open(installUrl, '_blank'); + + if (installWindow) { + setGitHubInstallationWindow(installWindow); + } + } + return; + } catch (error) { + logger.error('Error opening GitHub installation:', error); + setIsPollingGitHub(false); + toast.error('Error opening GitHub installation page. Please try again.'); + return; } - return; } // Ensure we have a project ID if (!projectId) { - alert('Cannot publish: No project ID available'); + toast.error('Cannot publish: No project ID available'); return; } - // Set loading state - setIsPublishingToGitHub(true); + // If already synced and we have the URL, offer to open it + if (isGithubSyncComplete && projectData?.getProject?.githubRepoUrl) { + 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 { - // Call the syncProject mutation with GraphQL - const response = await fetch('/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ - query: ` - mutation SyncProject($projectId: String!) { - syncProject(projectId: $projectId) { - id - projectName - isSyncedWithGitHub - githubOwner - githubRepoName - githubRepoUrl - } - } - - `, - variables: { - projectId - } - }) - }); - - if (!response.ok) { - throw new Error(`HTTP error: ${response.status}`); - } - - const result = await response.json(); - - if (result.errors) { - throw new Error(result.errors[0].message || 'GraphQL error'); - } - - // Get the repo URL from the response - const repoUrl = result.data.syncProject.githubRepoUrl; - - // Success! - setIsGithubSyncComplete(true); - - alert('Successfully published to GitHub!'); - - // Open the repo in a new tab - if (repoUrl) { - const shouldOpen = window.confirm('Would you like to open the GitHub repository?'); - if (shouldOpen) { - window.open(repoUrl, '_blank'); + await syncProject({ + variables: { + projectId } - } + }); } catch (error) { - console.error('Error publishing to GitHub:', error); - alert(`Error publishing to GitHub: ${error.message || 'Unknown error'}`); - } finally { - setIsPublishingToGitHub(false); + // Error is handled by the mutation's onError callback + logger.error('Error in handlePublishToGitHub:', error); } }; @@ -166,8 +205,7 @@ const ResponsiveToolbar = ({ const a = document.createElement('a'); // Set the download URL with credentials included - const downloadUrl = `http://localhost:8080/download/project/${projectId}`; - + const downloadUrl = `/download/project/${projectId}`; const headers = new Headers(); if (token) { @@ -178,6 +216,7 @@ const ResponsiveToolbar = ({ const response = await fetch(downloadUrl, { method: 'GET', headers: headers, + credentials: 'include' }); if (!response.ok) { @@ -213,8 +252,8 @@ const ResponsiveToolbar = ({ window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (error) { - console.error('Error downloading project:', error); - // Could add a toast notification here + logger.error('Error downloading project:', error); + alert('Error downloading project. Please try again.'); } finally { setIsDownloading(false); } @@ -304,20 +343,28 @@ const ResponsiveToolbar = ({ )} @@ -329,10 +376,26 @@ const ResponsiveToolbar = ({ + )} diff --git a/frontend/src/graphql/request.ts b/frontend/src/graphql/request.ts index 7503024c..ef2f2830 100644 --- a/frontend/src/graphql/request.ts +++ b/frontend/src/graphql/request.ts @@ -269,3 +269,27 @@ export const GET_USER_AVATAR = gql` getUserAvatar(userId: $userId) } `; + +// sync project with github +export const SYNC_PROJECT_TO_GITHUB = gql` + mutation SyncProjectToGitHub($projectId: String!) { + syncProjectToGitHub(projectId: $projectId) { + id + projectName + isSyncedWithGitHub + githubOwner + githubRepoName + githubRepoUrl + } + } +`; + +export const GET_PROJECT = gql` + query GetProject($projectId: String!) { + getProject(projectId: $projectId) { + id + isSyncedWithGitHub + githubRepoUrl + } + } +`; From 779a65aa2fbef7fc609b2f258dc2f1a0d29ec960 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Thu, 20 Mar 2025 23:58:05 -0400 Subject: [PATCH 08/17] create long term access --- backend/src/chat/chat.module.ts | 2 ++ backend/src/github/github.module.ts | 9 +++++---- backend/src/github/githubApp.service.ts | 2 +- backend/src/project/project.service.ts | 3 +-- backend/src/user/user.model.ts | 2 +- backend/src/user/user.module.ts | 4 +++- backend/src/user/user.service.ts | 8 +++++++- frontend/src/graphql/schema.gql | 2 +- 8 files changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/src/chat/chat.module.ts b/backend/src/chat/chat.module.ts index df69a066..d3229c6b 100644 --- a/backend/src/chat/chat.module.ts +++ b/backend/src/chat/chat.module.ts @@ -11,6 +11,7 @@ import { UserService } from 'src/user/user.service'; import { PubSub } from 'graphql-subscriptions'; import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module'; import { UploadModule } from 'src/upload/upload.module'; +import { GitHubModule } from 'src/github/github.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { UploadModule } from 'src/upload/upload.module'; AuthModule, JwtCacheModule, UploadModule, + GitHubModule, ], controllers: [ChatController], providers: [ diff --git a/backend/src/github/github.module.ts b/backend/src/github/github.module.ts index ceb584a8..c924e5c8 100644 --- a/backend/src/github/github.module.ts +++ b/backend/src/github/github.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from '../auth/auth.module'; import { ProjectGuard } from '../guard/project.guard'; @@ -14,7 +14,7 @@ import { ProjectPackages } from 'src/project/project-packages.model'; import { GitHuController } from './github.controller'; import { ProjectService } from 'src/project/project.service'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { UserService } from 'src/user/user.service'; +import { UserModule } from 'src/user/user.module'; @Module({ imports: [ @@ -22,10 +22,11 @@ import { UserService } from 'src/user/user.service'; AuthModule, AppConfigModule, UploadModule, - ConfigModule + ConfigModule, + forwardRef(() => UserModule), ], controllers: [GitHuController], - providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService, UserService], + providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService], exports: [GitHubService], }) export class GitHubModule {} diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts index a0ad6757..17fb3f77 100644 --- a/backend/src/github/githubApp.service.ts +++ b/backend/src/github/githubApp.service.ts @@ -67,7 +67,7 @@ export class GitHubAppService { await this.userRepo.update( { githubInstallationId: installationId }, { githubInstallationId: null, - githubCode: null + githubAccessToken: null } ); diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index bd0a2d48..ab190ac8 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -808,8 +808,7 @@ export class ProjectService { user.githubInstallationId, ); - const githubCode = user.githubCode; - const userOAuthToken = await this.gitHubService.exchangeOAuthCodeForToken(githubCode); + const userOAuthToken = user.githubAccessToken; // 4) Create the repo if the project doesn’t have it yet if (!project.githubRepoName || !project.githubOwner) { diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index 8da87415..eefa54d1 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -84,7 +84,7 @@ export class User extends SystemBaseModel { @Field({ nullable: true }) @Column({ nullable: true }) - githubCode?: string; + githubAccessToken?: string; /** * This field is maintained for API compatibility but is no longer actively used. diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 20f3b5c0..296675bd 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { UserResolver } from './user.resolver'; import { UserService } from './user.service'; import { DateScalar } from 'src/common/scalar/date.scalar'; @@ -8,6 +8,7 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthModule } from 'src/auth/auth.module'; import { MailModule } from 'src/mail/mail.module'; import { UploadModule } from 'src/upload/upload.module'; +import { GitHubModule } from 'src/github/github.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { UploadModule } from 'src/upload/upload.module'; AuthModule, MailModule, UploadModule, + forwardRef(() => GitHubModule), ], providers: [UserResolver, UserService, DateScalar], exports: [UserService], diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 99e886c9..05f536d7 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FileUpload } from 'graphql-upload-minimal'; import { UploadService } from '../upload/upload.service'; import { validateAndBufferFile } from 'src/common/security/file_check'; +import { GitHubService } from 'src/github/github.service'; @Injectable() export class UserService { @@ -12,6 +13,7 @@ export class UserService { @InjectRepository(User) private userRepository: Repository, private readonly uploadService: UploadService, + private readonly gitHubService: GitHubService, ) {} // Method to get all chats of a user @@ -72,8 +74,12 @@ export class UserService { } console.log(`Binding GitHub installation ID ${installationId} to user ${githubCode}`); + + //First request to GitHub to exchange the code for an access token (Wont expire) + const accessToken = await this.gitHubService.exchangeOAuthCodeForToken(githubCode); + user.githubInstallationId = installationId; - user.githubCode = githubCode; + user.githubAccessToken = accessToken; await this.userRepository.save(user); diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 4b7879c7..fb0d7b3b 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -246,7 +246,7 @@ type User { chats: [Chat!]! createdAt: Date! email: String! - githubCode: String + githubAccessToken: String githubInstallationId: String id: ID! isActive: Boolean! From 5230d4ca88945d17fd605ef19c67a8b2493a84f2 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 00:02:35 -0400 Subject: [PATCH 09/17] should not expose token --- backend/src/user/user.model.ts | 1 - frontend/src/graphql/schema.gql | 1 - 2 files changed, 2 deletions(-) diff --git a/backend/src/user/user.model.ts b/backend/src/user/user.model.ts index eefa54d1..4a3b908e 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -82,7 +82,6 @@ export class User extends SystemBaseModel { @Column({ nullable: true }) githubInstallationId?: string; - @Field({ nullable: true }) @Column({ nullable: true }) githubAccessToken?: string; diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index fb0d7b3b..60f8038c 100644 --- a/frontend/src/graphql/schema.gql +++ b/frontend/src/graphql/schema.gql @@ -246,7 +246,6 @@ type User { chats: [Chat!]! createdAt: Date! email: String! - githubAccessToken: String githubInstallationId: String id: ID! isActive: Boolean! From 8e8ddeeb3d06fac7992e31e63b508fced669ce9d Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 10:15:05 -0400 Subject: [PATCH 10/17] fix bug --- backend/src/github/github.service.ts | 7 ++++++- backend/src/github/githubApp.service.ts | 8 ++++---- backend/src/project/project.resolver.ts | 1 + backend/src/project/project.service.ts | 5 ++--- backend/src/user/user.service.ts | 13 +++++++++++-- frontend/src/components/github-callback.tsx | 10 ++++++++-- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts index 6be34077..9b6892b5 100644 --- a/backend/src/github/github.service.ts +++ b/backend/src/github/github.service.ts @@ -1,6 +1,6 @@ // src/github/github.service.ts -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import * as jwt from 'jsonwebtoken'; import axios from 'axios'; import * as fs from 'fs'; @@ -96,6 +96,11 @@ export class GitHubService { }, }, ); + + if (response.data.error) { + console.error('GitHub OAuth error:', response.data); + throw new BadRequestException(`GitHub OAuth error: ${response.data.error_description}`); + } const accessToken = response.data.access_token; if (!accessToken) { diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts index 17fb3f77..430e911f 100644 --- a/backend/src/github/githubApp.service.ts +++ b/backend/src/github/githubApp.service.ts @@ -83,10 +83,10 @@ export class GitHubAppService { } }); - // only for webhooks debugging - this.app.webhooks.onAny(async (event) => { - this.logger.log(`onAny: Received event='${event.name}' action='${event.payload}'`); - }); + // // only for webhooks debugging + // this.app.webhooks.onAny(async (event) => { + // this.logger.log(`onAny: Received event='${event.name}' action='${event.payload}'`); + // }); } /** diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index 90782a08..480ac887 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -167,6 +167,7 @@ export class ProjectsResolver { @Mutation(() => Project) async syncProjectToGitHub(@Args('projectId') projectId: string, @GetUserIdFromToken() userId: string,) { + // TODO: MAKE PUBLIC DYNAMIC 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 ab190ac8..ce8259fa 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -803,11 +803,10 @@ export class ProjectService { throw new Error('GitHub App not installed for this user'); } - // 3) Get the installation token + // 3) Get the installation and OAUTH token const installationToken = await this.gitHubService.getInstallationToken( user.githubInstallationId, ); - const userOAuthToken = user.githubAccessToken; // 4) Create the repo if the project doesn’t have it yet @@ -816,7 +815,7 @@ export class ProjectService { const repoName = project.projectName .replace(/\s+/g, '-') .toLowerCase() // e.g. "my-project" - + '-' + project.githubOwner; // to make it unique if needed + + '-' + "ChangeME"; // to make it unique if needed const { owner, repo, htmlUrl } = await this.gitHubService.createUserRepo( repoName, diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 05f536d7..392c964e 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -73,7 +73,11 @@ export class UserService { throw new BadRequestException('User already linked to a GitHub installation.'); } - console.log(`Binding GitHub installation ID ${installationId} to user ${githubCode}`); + if (!githubCode) { + throw new BadRequestException('Missing GitHub OAuth code'); + } + + 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); @@ -81,7 +85,12 @@ export class UserService { user.githubInstallationId = installationId; user.githubAccessToken = accessToken; - await this.userRepository.save(user); + try { + await this.userRepository.save(user); + } 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/components/github-callback.tsx b/frontend/src/components/github-callback.tsx index a86ee8b0..3f0469ee 100644 --- a/frontend/src/components/github-callback.tsx +++ b/frontend/src/components/github-callback.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthContext } from '@/providers/AuthProvider'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,8 +14,12 @@ export default function GitHubCallback() { const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [errorMessage, setErrorMessage] = useState(null); + + const hasCalledBackend = useRef(false); // Add guard flag here useEffect(() => { + if (hasCalledBackend.current) return; // Prevent multiple calls + // Extract installation ID from search params const githubCode = searchParams.get('code'); const installationId = searchParams.get('installation_id'); @@ -43,6 +47,8 @@ export default function GitHubCallback() { setErrorMessage('No installation ID was provided.'); return; } + + hasCalledBackend.current = true; // Call the backend directly to store the installation ID const storeInstallation = async () => { @@ -82,7 +88,7 @@ export default function GitHubCallback() { // Function to handle redirect back to projects const handleContinue = () => { - router.push('/projects'); // Change this to your desired redirect path + router.push('/'); // Change this to your desired redirect path }; return ( From c950e11fa6f8d5f4fdd2d29ef49d4bf1c3b92348 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 10:34:03 -0400 Subject: [PATCH 11/17] fix download button --- .../chat/code-engine/responsive-toolbar.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index bfc80322..e5f2a727 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -203,35 +203,35 @@ const ResponsiveToolbar = ({ try { // Create a hidden anchor element for download const a = document.createElement('a'); - + + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; // Set the download URL with credentials included - const downloadUrl = `/download/project/${projectId}`; + const downloadUrl = `${backendUrl}/download/project/${projectId}`; const headers = new Headers(); if (token) { headers.append('Authorization', `Bearer ${token}`); } - + // Fetch with credentials to ensure auth is included const response = await fetch(downloadUrl, { method: 'GET', headers: headers, - credentials: 'include' }); - + if (!response.ok) { throw new Error(`Download failed: ${response.status}`); } - + // Get the blob from the response const blob = await response.blob(); - + // Create a URL for the blob const url = window.URL.createObjectURL(blob); - + // Set the anchor's href to the blob URL a.href = url; - + // Set download attribute with filename from Content-Disposition header or default const contentDisposition = response.headers.get('Content-Disposition'); const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; @@ -239,21 +239,21 @@ const ResponsiveToolbar = ({ const filename = matches && matches[1] ? matches[1].replace(/['"]/g, '') : `project-${projectId}.zip`; - + a.download = filename; - + // Append to the document document.body.appendChild(a); - + // Click the anchor to start download a.click(); - + // Clean up window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (error) { - logger.error('Error downloading project:', error); - alert('Error downloading project. Please try again.'); + console.error('Error downloading project:', error); + // Could add a toast notification here } finally { setIsDownloading(false); } From 0dfde25f10af934f32ab91d7c4de95f29ae72398 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 13:38:57 -0400 Subject: [PATCH 12/17] debug --- backend/src/github/github.service.ts | 57 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts index 9b6892b5..f3483dd3 100644 --- a/backend/src/github/github.service.ts +++ b/backend/src/github/github.service.ts @@ -82,35 +82,42 @@ export class GitHubService { const clientId = this.configService.get('GITHUB_CLIENT_ID'); const clientSecret = this.configService.get('GITHUB_CLIENT_SECRET'); - console.log('code', code); - const response = await axios.post( - 'https://github.com/login/oauth/access_token', - { - client_id: clientId, - client_secret: clientSecret, - code: code, - }, - { - headers: { - Accept: 'application/json', + console.log('Exchanging OAuth Code:', { code, clientId, clientSecretExists: !!clientSecret }); + + try { + const response = await axios.post( + 'https://github.com/login/oauth/access_token', + { + client_id: clientId, + client_secret: clientSecret, + code: code, }, - }, - ); - - if (response.data.error) { - console.error('GitHub OAuth error:', response.data); - throw new BadRequestException(`GitHub OAuth error: ${response.data.error_description}`); - } + { + headers: { + Accept: 'application/json', + }, + }, + ); - const accessToken = response.data.access_token; - if (!accessToken) { - console.log(response.data); - throw new Error('Failed to exchange OAuth code for token'); - } + 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}`); + } + + const accessToken = response.data.access_token; + if (!accessToken) { + throw new Error('GitHub token exchange failed: No access token returned.'); + } - return accessToken; + return accessToken; + } catch (error: any) { + 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. From cb7283515d8fcd4c595f3ae974f7d174bd385a00 Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 13:39:15 -0400 Subject: [PATCH 13/17] remove repo info when user trigger --- backend/src/github/githubApp.service.ts | 58 ++++++++++++++++++++++--- backend/src/project/project.service.ts | 3 ++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts index 430e911f..9e922461 100644 --- a/backend/src/github/githubApp.service.ts +++ b/backend/src/github/githubApp.service.ts @@ -7,17 +7,23 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from 'src/user/user.model'; import { Repository } from 'typeorm'; +import { Project } from 'src/project/project.model'; @Injectable() export class GitHubAppService { private readonly logger = new Logger(GitHubAppService.name); private readonly app: App; + + //For local testing. You must use smee //smee -u https://smee.io/asdasd -t http://127.0.0.1:8080/github/webhook + constructor( private configService: ConfigService, @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Project) + private readonly projectRepo: Repository, ) { // Load from environment or config const appId = this.configService.get('GITHUB_APP_ID'); @@ -56,7 +62,8 @@ 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}`); const installationId = payload.installation.id.toString(); @@ -74,6 +81,47 @@ export class GitHubAppService { this.logger.log(`Cleared installationId for user: ${installationId}`); }); + // Handle when github repo removed + this.app.webhooks.on('installation_repositories', async ({ payload }) => { + 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: { + githubRepoName: repoName, + githubOwner: repoOwner, + }, + }); + + if (!project) { + 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') { @@ -83,10 +131,10 @@ export class GitHubAppService { } }); - // // only for webhooks debugging - // this.app.webhooks.onAny(async (event) => { - // this.logger.log(`onAny: Received event='${event.name}' action='${event.payload}'`); - // }); + // only for webhooks debugging + this.app.webhooks.onAny(async (event) => { + this.logger.log(`onAny: Received event='${event.name}' action='${event.payload}'`); + }); } /** diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index ce8259fa..dda2c1aa 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -798,6 +798,7 @@ export class ProjectService { throw new Error('Project not found'); } + 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'); @@ -812,6 +813,8 @@ export class ProjectService { // 4) Create the repo if the project doesn’t have it yet if (!project.githubRepoName || !project.githubOwner) { // 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" From 764889c1f60e6b0e9db4d3b8995fea4b011312dc Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 13:39:31 -0400 Subject: [PATCH 14/17] update .env.example --- frontend/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/.env.example b/frontend/.env.example index a29b0d57..b9fc9679 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,5 @@ NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8080/graphql +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 # TLS OPTION for HTTPS TLS=false From 2dd6a61959d61d558ae45def00ffbddc1d3f922b Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 13:39:55 -0400 Subject: [PATCH 15/17] avoid multiple call --- frontend/src/components/github-callback.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/github-callback.tsx b/frontend/src/components/github-callback.tsx index 3f0469ee..c7d288ea 100644 --- a/frontend/src/components/github-callback.tsx +++ b/frontend/src/components/github-callback.tsx @@ -18,13 +18,13 @@ export default function GitHubCallback() { const hasCalledBackend = useRef(false); // Add guard flag here useEffect(() => { - if (hasCalledBackend.current) return; // Prevent multiple calls - // Extract installation ID from search params const githubCode = searchParams.get('code'); const installationId = searchParams.get('installation_id'); const setupAction = searchParams.get('setup_action'); + if (!token || hasCalledBackend.current) return; // Prevent multiple calls + console.log('GitHub Callback:', { githubCode, installationId, setupAction }); // If there's no installation ID, this might be a cancellation @@ -84,7 +84,7 @@ export default function GitHubCallback() { }; storeInstallation(); - }, [searchParams, token]); + }, []); // Function to handle redirect back to projects const handleContinue = () => { From 5c2abd7621f3e03b81ac46f8f7fec0725b213f4a Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 13:40:09 -0400 Subject: [PATCH 16/17] synced project --- .../chat/code-engine/responsive-toolbar.tsx | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index e5f2a727..de98f8a8 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -37,18 +37,23 @@ const ResponsiveToolbar = ({ const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const { token, user, refreshUserInfo } = useAuthContext(); + const [syncedProject, setSyncedProject] = useState(null); // Poll for GitHub installation status when needed const [isPollingGitHub, setIsPollingGitHub] = useState(false); - const [gitHubInstallationWindow, setGitHubInstallationWindow] = useState(null); // Apollo mutations and queries const [syncProject, { loading: isPublishingToGitHub }] = useMutation(SYNC_PROJECT_TO_GITHUB, { onCompleted: (data) => { + + const syncResult = data.syncProjectToGitHub; + setSyncedProject(syncResult); + toast.success('Successfully published to GitHub!'); // Offer to open the repo in a new tab - const repoUrl = data.syncProject.githubRepoUrl; + 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) { @@ -66,11 +71,11 @@ const ResponsiveToolbar = ({ 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; + const isGithubSyncComplete = syncedProject?.isSyncedWithGitHub || projectData?.getProject?.isSyncedWithGitHub || false; + const githubRepoUrl = syncedProject?.githubRepoUrl || projectData?.getProject?.githubRepoUrl || ''; // Observe container width changes useEffect(() => { @@ -106,32 +111,32 @@ 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 { - // Check if the installation window is still open - if (gitHubInstallationWindow && gitHubInstallationWindow.closed) { - logger.info('GitHub installation window closed, checking installation status'); - - // Refresh user data to get updated installation status - await refreshUserInfo(); - - // Stop polling + // Call to refresh user info (from backend) + await refreshUserInfo(); + + // Check if user now has installation ID + if (user?.githubInstallationId) { + console.log('GitHub installation complete!'); setIsPollingGitHub(false); - setGitHubInstallationWindow(null); + clearInterval(pollInterval); } } catch (error) { - logger.error('Error polling for GitHub installation status:', error); + logger.error('Polling error:', error); setIsPollingGitHub(false); } - }, 2000); // Poll every 2 seconds + }, 3000); // Poll every 3s } - + return () => { if (pollInterval) clearInterval(pollInterval); }; - }, [isPollingGitHub, gitHubInstallationWindow, refreshUserInfo]); + }, [isPollingGitHub, user?.githubInstallationId, refreshUserInfo]); + // No need for a manual check function since we're using Apollo useQuery now @@ -153,11 +158,7 @@ const ResponsiveToolbar = ({ // This format ensures GitHub will prompt the user to choose where to install const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; - const installWindow = window.open(installUrl, '_blank'); - - if (installWindow) { - setGitHubInstallationWindow(installWindow); - } + window.open(installUrl, '_blank'); } return; } catch (error) { @@ -175,7 +176,7 @@ const ResponsiveToolbar = ({ } // If already synced and we have the URL, offer to open it - if (isGithubSyncComplete && projectData?.getProject?.githubRepoUrl) { + if (isGithubSyncComplete && githubRepoUrl) { 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'); From f0c186bebab172a62b8aa9fd716572a5b80c419c Mon Sep 17 00:00:00 2001 From: ZHallen122 Date: Fri, 21 Mar 2025 14:56:35 -0400 Subject: [PATCH 17/17] fix incorrect project id passing --- .../src/components/chat/code-engine/code-engine.tsx | 12 +----------- .../chat/code-engine/responsive-toolbar.tsx | 11 ++++------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/chat/code-engine/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 328c42ba..47c340bb 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -39,8 +39,6 @@ export function CodeEngine({ const editorRef = useRef(null); const projectPathRef = useRef(null); - const [activeProjectId, setActiveProjectId] = useState(projectId); - // Poll for project if needed using chatId useEffect(() => { if (!curProject && chatId && !projectLoading) { @@ -50,10 +48,6 @@ export function CodeEngine({ const project = await pollChatProject(chatId); if (project) { setLocalProject(project); - - if (project.id) { - setActiveProjectId(project.id); - } } } catch (error) { logger.error('Failed to load project from chat:', error); @@ -65,10 +59,6 @@ export function CodeEngine({ loadProjectFromChat(); } else { setIsLoading(projectLoading); - // If we have a current project from context, use its ID - if (curProject?.id) { - setActiveProjectId(curProject.id); - } } }, [chatId, curProject, projectLoading, pollChatProject]); @@ -299,7 +289,7 @@ export function CodeEngine({ isLoading={showLoader} activeTab={activeTab} setActiveTab={setActiveTab} - projectId={activeProjectId} + projectId={curProject?.id || projectId} />
diff --git a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx index de98f8a8..78fc8416 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -37,7 +37,6 @@ const ResponsiveToolbar = ({ const [compactIcons, setCompactIcons] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const { token, user, refreshUserInfo } = useAuthContext(); - const [syncedProject, setSyncedProject] = useState(null); // Poll for GitHub installation status when needed const [isPollingGitHub, setIsPollingGitHub] = useState(false); @@ -47,7 +46,6 @@ const ResponsiveToolbar = ({ onCompleted: (data) => { const syncResult = data.syncProjectToGitHub; - setSyncedProject(syncResult); toast.success('Successfully published to GitHub!'); @@ -71,11 +69,13 @@ const ResponsiveToolbar = ({ 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 = syncedProject?.isSyncedWithGitHub || projectData?.getProject?.isSyncedWithGitHub || false; - const githubRepoUrl = syncedProject?.githubRepoUrl || projectData?.getProject?.githubRepoUrl || ''; + const isGithubSyncComplete = projectData?.getProject?.isSyncedWithGitHub || false; + + const githubRepoUrl = projectData?.getProject?.githubRepoUrl || ''; // Observe container width changes useEffect(() => { @@ -136,9 +136,6 @@ const ResponsiveToolbar = ({ if (pollInterval) clearInterval(pollInterval); }; }, [isPollingGitHub, user?.githubInstallationId, refreshUserInfo]); - - - // No need for a manual check function since we're using Apollo useQuery now const handlePublishToGitHub = async () => { // If already publishing, do nothing