diff --git a/backend/.env.example b/backend/.env.example index c3c50c60..0b54bc76 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=true +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" 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/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.controller.ts b/backend/src/github/github.controller.ts new file mode 100644 index 00000000..6fca640d --- /dev/null +++ b/backend/src/github/github.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 GitHuController { + 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/github/github.module.ts b/backend/src/github/github.module.ts new file mode 100644 index 00000000..c924e5c8 --- /dev/null +++ b/backend/src/github/github.module.ts @@ -0,0 +1,32 @@ +import { forwardRef, 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 { GitHuController } from './github.controller'; +import { ProjectService } from 'src/project/project.service'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { UserModule } from 'src/user/user.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Project, Chat, User, ProjectPackages]), + AuthModule, + AppConfigModule, + UploadModule, + ConfigModule, + forwardRef(() => UserModule), + ], + controllers: [GitHuController], + providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService], + exports: [GitHubService], +}) +export class GitHubModule {} diff --git a/backend/src/github/github.service.ts b/backend/src/github/github.service.ts new file mode 100644 index 00000000..f3483dd3 --- /dev/null +++ b/backend/src/github/github.service.ts @@ -0,0 +1,252 @@ +// src/github/github.service.ts + +import { BadRequestException, 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'; +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,) + { + + 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('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, + }, + { + headers: { + Accept: 'application/json', + }, + }, + ); + + 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; + } 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. + */ + async createUserRepo( + repoName: string, + isPublic: boolean, + userOAuthToken: string, + ): Promise<{ + owner: string; + repo: string; + htmlUrl: string; + }> { + const url = `https://api.github.com/user/repos`; + + 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) { + + // Skip unwanted files + if (this.ignored.includes(entry.name)) { + continue; + } + + const entryPath = path.join(folderPath, entry.name); + if (entry.isDirectory()) { + // Skip unwanted directories + if (entry.name === '.git' || entry.name === 'node_modules') { + continue; + } + // Recurse into subdirectory + const subDirInRepo = path.join(basePathInRepo, entry.name).replace(/\\/g, '/'); + await this.pushFolderContent(installationToken, owner, repo, entryPath, subDirInRepo); + } else { + // It's a file; push it + const fileInRepo = path.join(basePathInRepo, entry.name).replace(/\\/g, '/'); + await this.pushFileContent( + installationToken, + owner, + repo, + entryPath, + fileInRepo, + `Add file: ${fileInRepo}`, + ); + } + } + } + +} diff --git a/backend/src/github/githubApp.service.ts b/backend/src/github/githubApp.service.ts new file mode 100644 index 00000000..9e922461 --- /dev/null +++ b/backend/src/github/githubApp.service.ts @@ -0,0 +1,146 @@ +// 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'; +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'); + 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); + }); + + // 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(); + + this.logger.log(`uninstall Created: installationId=${installationId}, GitHub Login=`); + + // remove user github code and installationId + await this.userRepo.update( + { githubInstallationId: installationId }, + { githubInstallationId: null, + githubAccessToken: null + } + ); + + 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') { + this.logger.error(`Webhook signature verification failed: ${error.event}`); + } else { + this.logger.error(error); + } + }); + + // only for webhooks debugging + 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/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/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.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 9145f7d2..518ac0c9 100644 --- a/backend/src/project/project.module.ts +++ b/backend/src/project/project.module.ts @@ -11,6 +11,9 @@ 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'; +import { GitHubService } from 'src/github/github.service'; +import { UserService } from 'src/user/user.service'; @Module({ imports: [ @@ -19,7 +22,8 @@ import { UploadModule } from 'src/upload/upload.module'; AppConfigModule, UploadModule, ], - providers: [ChatService, ProjectService, ProjectsResolver, ProjectGuard], + controllers: [DownloadController], + 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..480ac887 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -164,4 +164,10 @@ export class ProjectsResolver { ): Promise { return this.projectService.getRemainingProjectLimit(userId); } + + @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 56eac43d..dda2c1aa 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -31,6 +31,13 @@ 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'; +import { GitHubService } from 'src/github/github.service'; +import { UserService } from 'src/user/user.service'; + @Injectable() export class ProjectService { private readonly model: OpenAIModelProvider = @@ -46,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 { @@ -685,4 +694,159 @@ 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 }; + } + + /** + * 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'); + } + + 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'); + } + + // 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 + 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" + + '-' + "ChangeME"; // to make it unique if needed + + const { owner, repo, htmlUrl } = await this.gitHubService.createUserRepo( + repoName, + isPublic, + userOAuthToken + ); + + 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); + + // delete await for now, To make it background running + 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..4a3b908e 100644 --- a/backend/src/user/user.model.ts +++ b/backend/src/user/user.model.ts @@ -75,6 +75,16 @@ 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; + + @Column({ nullable: true }) + githubAccessToken?: 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.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 e4476b2e..392c964e 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,10 +1,11 @@ -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'; 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 @@ -60,4 +62,36 @@ 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.'); + } + + 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); + + user.githubInstallationId = installationId; + user.githubAccessToken = accessToken; + + 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/.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 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/code-engine.tsx b/frontend/src/components/chat/code-engine/code-engine.tsx index 8ac6da93..47c340bb 100644 --- a/frontend/src/components/chat/code-engine/code-engine.tsx +++ b/frontend/src/components/chat/code-engine/code-engine.tsx @@ -289,6 +289,7 @@ export function CodeEngine({ isLoading={showLoader} activeTab={activeTab} setActiveTab={setActiveTab} + 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 46161cac..78fc8416 100644 --- a/frontend/src/components/chat/code-engine/responsive-toolbar.tsx +++ b/frontend/src/components/chat/code-engine/responsive-toolbar.tsx @@ -1,30 +1,81 @@ '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, Copy, + Download, Eye, GitFork, + 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; 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, user, refreshUserInfo } = useAuthContext(); + + // Poll for GitHub installation status when needed + const [isPollingGitHub, setIsPollingGitHub] = useState(false); + + // Apollo mutations and queries + const [syncProject, { loading: isPublishingToGitHub }] = useMutation(SYNC_PROJECT_TO_GITHUB, { + onCompleted: (data) => { + + const syncResult = data.syncProjectToGitHub; + + toast.success('Successfully published to GitHub!'); + + // Offer to open the repo in a new tab + const repoUrl = syncResult.githubRepoUrl; + console.log('GitHub repo URL:', repoUrl); + if (repoUrl) { + const shouldOpen = window.confirm('Would you like to open the GitHub repository?'); + if (shouldOpen) { + window.open(repoUrl, '_blank'); + } + } + }, + onError: (error) => { + logger.error('Error publishing to GitHub:', error); + toast.error(`Error publishing to GitHub: ${error.message}`); + } + }); + + // Query to check if the project is already synced + const { data: projectData } = useQuery(GET_PROJECT, { + variables: { projectId }, + skip: !projectId, + fetchPolicy: 'cache-and-network', + }); + + // Determine if GitHub sync is complete based on query data + const isGithubSyncComplete = projectData?.getProject?.isSyncedWithGitHub || false; + + const githubRepoUrl = projectData?.getProject?.githubRepoUrl || ''; // Observe container width changes useEffect(() => { @@ -57,6 +108,156 @@ const ResponsiveToolbar = ({ } }, [containerWidth]); + // Poll for GitHub installation completion + useEffect(() => { + let pollInterval: NodeJS.Timeout; + + if (isPollingGitHub) { + pollInterval = setInterval(async () => { + console.log('Polling backend for GitHub installation status...'); + try { + // Call to refresh user info (from backend) + await refreshUserInfo(); + + // Check if user now has installation ID + if (user?.githubInstallationId) { + console.log('GitHub installation complete!'); + setIsPollingGitHub(false); + clearInterval(pollInterval); + } + } catch (error) { + logger.error('Polling error:', error); + setIsPollingGitHub(false); + } + }, 3000); // Poll every 3s + } + + return () => { + if (pollInterval) clearInterval(pollInterval); + }; + }, [isPollingGitHub, user?.githubInstallationId, refreshUserInfo]); + + const handlePublishToGitHub = async () => { + // If already publishing, do nothing + if (isPublishingToGitHub) return; + + // If the user hasn't installed the GitHub App yet + if (!user?.githubInstallationId) { + try { + // 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) { + // Start polling for installation completion + setIsPollingGitHub(true); + + // This format ensures GitHub will prompt the user to choose where to install + const installUrl = `https://github.com/apps/codefox-project-fork/installations/new`; + window.open(installUrl, '_blank'); + } + return; + } catch (error) { + logger.error('Error opening GitHub installation:', error); + setIsPollingGitHub(false); + toast.error('Error opening GitHub installation page. Please try again.'); + return; + } + } + + // Ensure we have a project ID + if (!projectId) { + toast.error('Cannot publish: No project ID available'); + return; + } + + // If already synced and we have the URL, offer to open it + if (isGithubSyncComplete && githubRepoUrl) { + const shouldOpen = window.confirm('This project is already published to GitHub. Would you like to open the repository?'); + if (shouldOpen) { + window.open(projectData.getProject.githubRepoUrl, '_blank'); + } + return; + } + + // Execute the mutation + try { + await syncProject({ + variables: { + projectId + } + }); + } catch (error) { + // Error is handled by the mutation's onError callback + logger.error('Error in handlePublishToGitHub:', error); + } + }; + + 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'); + + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8080'; + // Set the download URL with credentials included + 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, + }); + + 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 +403,4 @@ const ResponsiveToolbar = ({ ); }; -export default ResponsiveToolbar; +export default ResponsiveToolbar; \ No newline at end of file diff --git a/frontend/src/components/github-callback.tsx b/frontend/src/components/github-callback.tsx new file mode 100644 index 00000000..c7d288ea --- /dev/null +++ b/frontend/src/components/github-callback.tsx @@ -0,0 +1,163 @@ +'use client'; +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'; +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); + + const hasCalledBackend = useRef(false); // Add guard flag here + + useEffect(() => { + // 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 + 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; + } + + hasCalledBackend.current = true; + + // 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(); + }, []); + + // Function to handle redirect back to projects + const handleContinue = () => { + router.push('/'); // 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..ef2f2830 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 } } `; @@ -268,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 + } + } +`; diff --git a/frontend/src/graphql/schema.gql b/frontend/src/graphql/schema.gql index 6c419a01..60f8038c 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,7 @@ type User { chats: [Chat!]! createdAt: Date! email: 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;