Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"graphql-subscriptions": "^2.0.0",
"graphql-upload-minimal": "^1.6.1",
"graphql-ws": "^5.16.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"markdown-to-txt": "^2.0.1",
"nodemailer": "^6.10.0",
Expand All @@ -84,6 +85,7 @@
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.16.12",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
Expand Down
74 changes: 54 additions & 20 deletions backend/src/github/github.controller.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,81 @@
// src/github/github-webhook.controller.ts

import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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;
export class GitHubController {
private webhooks: any;

constructor(private readonly gitHubAppService: GitHubAppService, private readonly userService: UserService) {
// Get the App instance from the service
const app = this.gitHubAppService.getApp();
constructor(
private configService: ConfigService,
private userService: UserService,
) {
this.initWebhooks();
}

private async initWebhooks() {
const { Webhooks } = await import('@octokit/webhooks');
this.webhooks = new Webhooks({
secret: this.configService.get('GITHUB_WEBHOOK_SECRET'),
});

// Create the Express-style middleware from @octokit/webhooks
this.webhookMiddleware = createNodeMiddleware(app.webhooks, {
path: '/github/webhook',
this.webhooks.on('push', ({ payload }) => {
console.log(`📦 Received push event for ${payload.repository.full_name}`);
});
}

// deal GitHub webhook rollbacks
@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');

// wait webhooks initialize finish
if (!this.webhooks) {
return res.status(503).send('Webhook system not ready');
}

let rawBody = '';
req.on('data', (chunk) => {
rawBody += chunk;
});

req.on('end', async () => {
try {
const id = req.headers['x-github-delivery'] as string;
const name = req.headers['x-github-event'] as string;
const signature = req.headers['x-hub-signature-256'] as string;
const body = JSON.parse(rawBody);

await this.webhooks.receive({
id,
name,
payload: body,
signature,
});

return res.sendStatus(200);
} catch (err) {
console.error('❌ Error handling webhook:', err);
return res.status(500).send('Internal Server Error');
}
});
}


// store GitHub installation info
@Post('storeInstallation')
async storeInstallation(
@Body() body: { installationId: string, githubCode: string },
@Body() body: { installationId: string; githubCode: string },
@GetUserIdFromToken() userId: string,
) {
await this.userService.bindUserIdAndInstallId(userId, body.installationId, body.githubCode);
await this.userService.bindUserIdAndInstallId(
userId,
body.installationId,
body.githubCode,
);
return { success: true };
}
}
4 changes: 2 additions & 2 deletions backend/src/github/github.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { GitHuController } from './github.controller';
import { GitHubController } from './github.controller';
import { ProjectService } from 'src/project/project.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserModule } from 'src/user/user.module';
Expand All @@ -25,7 +25,7 @@ import { UserModule } from 'src/user/user.module';
ConfigModule,
forwardRef(() => UserModule),
],
controllers: [GitHuController],
controllers: [GitHubController],
providers: [ProjectService, ProjectGuard, GitHubAppService, GitHubService, ConfigService, ChatService],
exports: [GitHubService],
})
Expand Down
80 changes: 24 additions & 56 deletions backend/src/github/githubApp.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// src/github/github-app.service.ts

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { App, Octokit} from 'octokit';
import { Injectable, Logger } from '@nestjs/common';
import { readFileSync } from 'fs';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
Expand All @@ -12,31 +9,32 @@ 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
private app: any;

constructor(
private configService: ConfigService,
private configService: ConfigService,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
) {
// Load from environment or config
// use constructor to initialize the app
this.initApp();
}

private async initApp() {
// dynamic import fix eslint error
const { App, Octokit } = await import('octokit');

const appId = this.configService.get('GITHUB_APP_ID');
const privateKeyPath = this.configService.get('GITHUB_PRIVATE_KEY_PATH');
const secret = this.configService.get('GITHUB_WEBHOOK_SECRET');
const enterpriseHostname = this.configService.get('enterpriseHostname') || "";
const enterpriseHostname =
this.configService.get('enterpriseHostname') || '';

// Read the private key from file
const privateKey = readFileSync(privateKeyPath, 'utf8');

// Instantiate the GitHub App
if (enterpriseHostname) {
// Use custom hostname ONLY if it's non-empty
this.app = new App({
appId,
privateKey,
Expand All @@ -53,7 +51,6 @@ export class GitHubAppService {
});
}

// Optional: see who you're authenticated as
this.app.octokit
.request('/app')
.then((res) => {
Expand All @@ -62,67 +59,42 @@ 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();

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
}
{ 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;
}

if (!removedRepos || removedRepos.length === 0) 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

if (!project) continue;

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}`);
Expand All @@ -131,16 +103,12 @@ export class GitHubAppService {
}
});

// 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 {
getApp() {
return this.app;
}
}
2 changes: 1 addition & 1 deletion backend/src/project/project.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +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';
import { DownloadController } from './downloadController';
import { GitHubService } from 'src/github/github.service';
import { UserService } from 'src/user/user.service';

Expand Down
Loading
Loading