diff --git a/.gitignore b/.gitignore
index a53a7b9..e17e1cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,6 @@ package-lock.json
# PDF
*.pdf
+
+# Pem files
+*.pem
diff --git a/README.md b/README.md
index 780647e..c6d88d3 100644
--- a/README.md
+++ b/README.md
@@ -1,94 +1 @@
-# SaaS Boilerplate with Turborepo
-
-This is a modern SaaS boilerplate built with Turborepo, providing a scalable foundation for building your next SaaS application. It combines the power of Next.js, TypeScript, and other modern technologies to help you launch faster.
-
-## Features
-
-- 🚀 **Modern Stack**: Built with Next.js, TypeScript, and Turborepo
-- 🎨 **Beautiful UI**: Pre-configured with Tailwind CSS and modern UI components
-- 🔒 **Authentication**: Ready-to-use authentication system
-- 💳 **Payments**: Integrated payment processing capabilities
-- 📱 **Responsive**: Mobile-first design approach
-- 🔧 **Developer Experience**: Hot reloading, TypeScript, ESLint, and Prettier
-
-## What's inside?
-
-This Turborepo includes the following packages/apps:
-
-### Apps and Packages
-
-- `docs`: a [Next.js](https://nextjs.org/) app for documentation
-- `web`: the main [Next.js](https://nextjs.org/) SaaS application
-- `@repo/ui`: a shared React component library with pre-built UI components
-- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
-- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
-
-Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
-
-### Key Technologies
-
-- [Next.js](https://nextjs.org/) - React framework for production
-- [TypeScript](https://www.typescriptlang.org/) - Static type checking
-- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
-- [Turborepo](https://turbo.build/) - High-performance build system
-- [ESLint](https://eslint.org/) - Code linting
-- [Prettier](https://prettier.io) - Code formatting
-
-### Utilities
-
-This Turborepo has some additional tools already setup for you:
-
-- [TypeScript](https://www.typescriptlang.org/) for static type checking
-- [ESLint](https://eslint.org/) for code linting
-- [Prettier](https://prettier.io) for code formatting
-
-### Build
-
-To build all apps and packages, run the following command:
-
-```
-cd my-turborepo
-pnpm build
-```
-
-### Develop
-
-To develop all apps and packages, run the following command:
-
-```
-cd my-turborepo
-pnpm dev
-```
-
-### Remote Caching
-
-> [!TIP]
-> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
-
-Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
-
-By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
-
-```
-cd my-turborepo
-npx turbo login
-```
-
-This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
-
-Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
-
-```
-npx turbo link
-```
-
-## Useful Links
-
-Learn more about the power of Turborepo:
-
-- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
-- [Caching](https://turbo.build/repo/docs/core-concepts/caching)
-- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
-- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
-- [Configuration Options](https://turbo.build/repo/docs/reference/configuration)
-- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference)
+# Angry Beard Bot
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 13ddac8..e7d19ca 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -18,3 +18,12 @@ SUPABASE_BUCKET_NAME=
# Logger
LOG_LEVEL=
+
+# Workflow
+WORKFLOW_URL=
+
+# Github
+GITHUB_APP_ID=
+GITHUB_PRIVATE_KEY=
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
diff --git a/apps/api/docker/Dockerfile b/apps/api/docker/Dockerfile
new file mode 100644
index 0000000..793c590
--- /dev/null
+++ b/apps/api/docker/Dockerfile
@@ -0,0 +1,34 @@
+# Stage 1: Build
+FROM node:18-alpine AS builder
+
+# Create working directory
+WORKDIR /app
+
+# Copy dependency files
+COPY ../../yarn.lock ../../package.json ./
+
+# Install dependencies
+RUN yarn install
+
+# Copy rest of project
+COPY ../../ .
+
+# Build application
+RUN yarn build
+
+# Stage 2: Production
+FROM node:18-alpine
+
+WORKDIR /app
+
+# Copy only necessary files from build stage
+COPY --from=builder /app/package.json ./
+COPY --from=builder /app/yarn.lock ./
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/dist ./dist
+
+# Default Nest port
+EXPOSE 3000
+
+# Command to start app
+CMD ["node", "dist/main"]
diff --git a/apps/api/package.json b/apps/api/package.json
index 2ac60d9..e462a5a 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -50,6 +50,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"graphql": "^16.10.0",
+ "jsonwebtoken": "^9.0.2",
"passport": "^0.7.0",
"prisma": "^6.5.0",
"reflect-metadata": "^0.2.0",
@@ -63,6 +64,7 @@
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
+ "@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts
index cce879e..2cede91 100644
--- a/apps/api/src/app.controller.ts
+++ b/apps/api/src/app.controller.ts
@@ -1,12 +1,20 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+@ApiTags('Health')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
- getHello(): string {
- return this.appService.getHello();
+ @ApiOperation({ summary: 'Get API health status' })
+ @ApiResponse({
+ status: 200,
+ description: 'Returns OK if the API is healthy',
+ type: String,
+ })
+ async getHealth(): Promise {
+ return this.appService.getHealth();
}
}
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 06b5541..cab1296 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -7,11 +7,14 @@ import { StripeModule } from './stripe/stripe.module';
import { ConfigModule } from '@nestjs/config';
import { SupabaseModule } from './supabase/supabase.module';
import { AuthModule } from './auth/auth.module';
-import { GithubModule } from './github/github.module';
import { RepositoryModule } from './repository/repository.module';
import { PullRequestModule } from './pull-request/pull-request.module';
import { UserModule } from './user/user.module';
import { SubscriptionModule } from './subscription/subscription.module';
+import { ReviewModule } from './review/review.module';
+import { WorkflowModule } from './workflow/workflow.module';
+import { GithubModule } from './github/github.module';
+import { BotConfigModule } from './bot-config/bot-config.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
@@ -24,6 +27,9 @@ import { SubscriptionModule } from './subscription/subscription.module';
PullRequestModule,
UserModule,
SubscriptionModule,
+ ReviewModule,
+ WorkflowModule,
+ BotConfigModule,
],
controllers: [AppController],
providers: [AppService, PrismaService],
diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts
index 927d7cc..5a29d58 100644
--- a/apps/api/src/app.service.ts
+++ b/apps/api/src/app.service.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
- getHello(): string {
- return 'Hello World!';
+ async getHealth(): Promise {
+ return 'OK';
}
}
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
index 1fa04a0..1a60344 100644
--- a/apps/api/src/auth/auth.controller.ts
+++ b/apps/api/src/auth/auth.controller.ts
@@ -1,8 +1,10 @@
import { Controller, Post, Body, Get, Logger } from '@nestjs/common';
import { SupabaseService } from '../supabase/supabase.service';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
-import { GitHubUser } from './interface/github-user.interface';
+import { AuthRequest } from './interface/auth-request.interface';
import { UserService } from 'src/user/user.service';
+import { Public } from './decorators/public.decorator';
+
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
@@ -15,6 +17,7 @@ export class AuthController {
this.logger = new Logger(AuthController.name);
}
+ @Public()
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiBody({
@@ -35,6 +38,7 @@ export class AuthController {
return { user };
}
+ @Public()
@Post('login')
@ApiOperation({ summary: 'Login user' })
@ApiBody({
@@ -65,32 +69,34 @@ export class AuthController {
return { session };
}
+ @Public()
@Post('github')
@ApiOperation({ summary: 'Register or update user with GitHub data' })
- @ApiBody({ type: GitHubUser })
+ //@ApiBody({ type: AuthRequest as ObjectType })
@ApiResponse({ status: 201, description: 'User successfully registered/updated' })
@ApiResponse({ status: 400, description: 'Invalid input' })
- async handleGithubAuth(@Body() githubUser: GitHubUser) {
- this.logger.log(`Processing GitHub authentication for user: ${githubUser.email}`);
+ async handleGithubAuth(@Body() authRequest: AuthRequest) {
+ this.logger.log(`Processing GitHub authentication for user: ${JSON.stringify(authRequest, null, 2)}`);
try {
- if (!githubUser.email) {
+ if (!authRequest.user.email) {
throw new Error('Email is required');
}
- const existingUser = await this.userService.getUserByEmail(githubUser.email);
+ const existingUser = await this.userService.getUserBySupabaseId(authRequest.user.id);
- let user = existingUser;
+ let currentUser = existingUser;
if (!existingUser) {
- user = await this.userService.createUser({
- email: githubUser.email,
- name: githubUser.user_metadata.name,
- githubId: githubUser.id,
- providerId: githubUser.user_metadata.provider_id,
+ currentUser = await this.userService.createUser({
+ email: authRequest.user.email,
+ name: authRequest.user.user_metadata.name,
+ githubId: authRequest.user.id,
+ providerId: authRequest.user.user_metadata.provider_id,
+ supabaseId: authRequest.user.id,
});
}
- return { user };
+ return { user: currentUser };
} catch (error) {
this.logger.error(`Error processing GitHub authentication: ${error.message}`);
throw error;
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts
index 2389b3c..f8314df 100644
--- a/apps/api/src/auth/auth.module.ts
+++ b/apps/api/src/auth/auth.module.ts
@@ -3,9 +3,18 @@ import { AuthController } from './auth.controller';
import { SupabaseService } from 'src/supabase/supabase.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UserModule } from 'src/user/user.module';
+import { APP_GUARD } from '@nestjs/core';
+import { AuthGuard } from './guards/auth.guard';
+
@Module({
imports: [PrismaModule, UserModule],
- providers: [SupabaseService],
+ providers: [
+ SupabaseService,
+ {
+ provide: APP_GUARD,
+ useClass: AuthGuard,
+ },
+ ],
controllers: [AuthController],
})
export class AuthModule {}
diff --git a/apps/api/src/auth/decorators/public.decorator.ts b/apps/api/src/auth/decorators/public.decorator.ts
new file mode 100644
index 0000000..b3845e1
--- /dev/null
+++ b/apps/api/src/auth/decorators/public.decorator.ts
@@ -0,0 +1,4 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const IS_PUBLIC_KEY = 'isPublic';
+export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
diff --git a/apps/api/src/auth/guards/auth.guard.ts b/apps/api/src/auth/guards/auth.guard.ts
new file mode 100644
index 0000000..efcd550
--- /dev/null
+++ b/apps/api/src/auth/guards/auth.guard.ts
@@ -0,0 +1,56 @@
+import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import { SupabaseService } from '../../supabase/supabase.service';
+import { Reflector } from '@nestjs/core';
+import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
+
+@Injectable()
+export class AuthGuard implements CanActivate {
+ constructor(
+ private supabaseService: SupabaseService,
+ private reflector: Reflector,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ // Check if the route is marked as public
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
+
+ if (isPublic) {
+ return true;
+ }
+
+ const request = context.switchToHttp().getRequest();
+ const authHeader = request.headers.authorization;
+
+ if (!authHeader) {
+ throw new UnauthorizedException('No authorization header');
+ }
+
+ const [type, token] = authHeader.split(' ');
+
+ if (type !== 'Bearer') {
+ throw new UnauthorizedException('Invalid authorization type');
+ }
+
+ if (!token) {
+ throw new UnauthorizedException('No token provided');
+ }
+
+ try {
+ // Verify the token with Supabase
+ const {
+ data: { user },
+ error,
+ } = await this.supabaseService.client.auth.getUser(token);
+
+ if (error || !user) {
+ throw new UnauthorizedException('Invalid token');
+ }
+
+ // Attach the user to the request for later use
+ request.user = user;
+ return true;
+ } catch (error) {
+ throw new UnauthorizedException('Invalid token');
+ }
+ }
+}
diff --git a/apps/api/src/auth/interface/github-user.interface.ts b/apps/api/src/auth/interface/auth-request.interface.ts
similarity index 58%
rename from apps/api/src/auth/interface/github-user.interface.ts
rename to apps/api/src/auth/interface/auth-request.interface.ts
index d46168c..b2c23a9 100644
--- a/apps/api/src/auth/interface/github-user.interface.ts
+++ b/apps/api/src/auth/interface/auth-request.interface.ts
@@ -17,11 +17,25 @@ export interface UserMetadata {
user_name: string;
}
+export interface IdentityData {
+ avatar_url: string;
+ email: string;
+ email_verified: boolean;
+ full_name: string;
+ iss: string;
+ name: string;
+ phone_verified: boolean;
+ preferred_username: string;
+ provider_id: string;
+ sub: string;
+ user_name: string;
+}
+
export interface Identity {
identity_id: string;
id: string;
user_id: string;
- identity_data: any;
+ identity_data: IdentityData;
provider: string;
last_sign_in_at: string;
created_at: string;
@@ -29,7 +43,23 @@ export interface Identity {
email: string;
}
-export class GitHubUser {
+export interface Session {
+ access_token: string;
+ token_type: string;
+ expires_in: number;
+ expires_at: number;
+ refresh_token: string;
+ user: User;
+ provider_token: string;
+}
+
+export interface AuthRequest {
+ session: Session;
+ user: User;
+ redirectType: string | null;
+}
+
+export interface User {
id: string;
aud: string;
role: string;
diff --git a/apps/api/src/bot-config/bot-config.controller.spec.ts b/apps/api/src/bot-config/bot-config.controller.spec.ts
new file mode 100644
index 0000000..3a3f327
--- /dev/null
+++ b/apps/api/src/bot-config/bot-config.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { BotConfigController } from './bot-config.controller';
+
+describe('BotConfigController', () => {
+ let controller: BotConfigController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [BotConfigController],
+ }).compile();
+
+ controller = module.get(BotConfigController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/api/src/bot-config/bot-config.controller.ts b/apps/api/src/bot-config/bot-config.controller.ts
new file mode 100644
index 0000000..3fdd6be
--- /dev/null
+++ b/apps/api/src/bot-config/bot-config.controller.ts
@@ -0,0 +1,89 @@
+import { Controller, Get, Post, Put, Delete, Param, Body, Logger } from '@nestjs/common';
+import { BotConfigService } from './bot-config.service';
+import { BotConfigDto, CreateBotConfigDto, UpdateBotConfigDto } from './dto/bot-config.dto';
+import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger';
+import { UserService } from 'src/user/user.service';
+@ApiTags('Bot Configuration')
+@Controller('bot-config')
+export class BotConfigController {
+ private readonly logger;
+
+ constructor(
+ private readonly botConfigService: BotConfigService,
+ private readonly userService: UserService,
+ ) {
+ this.logger = new Logger(BotConfigController.name);
+ }
+
+ @ApiOperation({ summary: 'Get bot configuration by ID' })
+ @ApiParam({ name: 'id', description: 'Bot configuration ID' })
+ @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto })
+ @ApiResponse({ status: 404, description: 'Bot configuration not found' })
+ @Get(':id')
+ async getBotConfigById(@Param('id') id: string): Promise {
+ this.logger.debug(`Getting bot config for id: ${id}`);
+ const botConfig = await this.botConfigService.getBotConfigById(id);
+ this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`);
+ return botConfig;
+ }
+
+ @ApiOperation({ summary: 'Get bot configuration by user ID' })
+ @ApiParam({ name: 'userId', description: 'User ID' })
+ @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto })
+ @ApiResponse({ status: 404, description: 'Bot configuration not found' })
+ @Get('user/:userId')
+ async getBotConfigByUserId(@Param('userId') userId: string): Promise {
+ this.logger.debug(`Getting bot config for userId: ${userId}`);
+ const botConfig = await this.botConfigService.getBotConfigByUserId(userId);
+ this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`);
+ return botConfig;
+ }
+
+ @ApiOperation({ summary: 'Get bot configuration by supabase ID' })
+ @ApiParam({ name: 'supabaseId', description: 'Supabase ID' })
+ @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto })
+ @ApiResponse({ status: 404, description: 'Bot configuration not found' })
+ @Get('supabase/:supabaseId')
+ async getBotConfigBySupabaseId(@Param('supabaseId') supabaseId: string): Promise {
+ this.logger.debug(`Getting bot config for supabaseId: ${supabaseId}`);
+ const user = await this.userService.getUserBySupabaseId(supabaseId);
+ this.logger.debug(`User: ${JSON.stringify(user)}`);
+ return user.botConfig;
+ }
+
+ @ApiOperation({ summary: 'Create new bot configuration' })
+ @ApiParam({ name: 'userId', description: 'User ID' })
+ @ApiBody({ type: CreateBotConfigDto })
+ @ApiResponse({ status: 201, description: 'Bot configuration created', type: BotConfigDto })
+ @Post(':userId')
+ async createBotConfig(@Body() botConfig: CreateBotConfigDto, @Param('userId') userId: string): Promise {
+ this.logger.debug(`Creating bot config: ${JSON.stringify(botConfig)}`);
+ const newBotConfig = await this.botConfigService.createBotConfig(userId, botConfig);
+ this.logger.debug(`New bot config: ${JSON.stringify(newBotConfig)}`);
+ return newBotConfig;
+ }
+
+ @ApiOperation({ summary: 'Update bot configuration' })
+ @ApiParam({ name: 'id', description: 'Bot configuration ID' })
+ @ApiBody({ type: UpdateBotConfigDto })
+ @ApiResponse({ status: 200, description: 'Bot configuration updated', type: BotConfigDto })
+ @ApiResponse({ status: 404, description: 'Bot configuration not found' })
+ @Put(':id')
+ async updateBotConfig(@Param('id') id: string, @Body() botConfig: UpdateBotConfigDto): Promise {
+ this.logger.debug(`Updating bot config for id: ${id}`);
+ const updatedBotConfig = await this.botConfigService.updateBotConfig(id, botConfig);
+ this.logger.debug(`Updated bot config: ${JSON.stringify(updatedBotConfig)}`);
+ return updatedBotConfig;
+ }
+
+ @ApiOperation({ summary: 'Delete bot configuration' })
+ @ApiParam({ name: 'id', description: 'Bot configuration ID' })
+ @ApiResponse({ status: 200, description: 'Bot configuration deleted successfully' })
+ @ApiResponse({ status: 404, description: 'Bot configuration not found' })
+ @Delete(':id')
+ async deleteBotConfig(@Param('id') id: string): Promise {
+ this.logger.debug(`Deleting bot config for id: ${id}`);
+ await this.botConfigService.deleteBotConfig(id);
+ this.logger.debug(`Bot config deleted for id: ${id}`);
+ }
+}
diff --git a/apps/api/src/bot-config/bot-config.module.ts b/apps/api/src/bot-config/bot-config.module.ts
new file mode 100644
index 0000000..ae08bda
--- /dev/null
+++ b/apps/api/src/bot-config/bot-config.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { BotConfigService } from './bot-config.service';
+import { BotConfigController } from './bot-config.controller';
+import { PrismaModule } from 'src/prisma/prisma.module';
+import { UserService } from 'src/user/user.service';
+@Module({
+ imports: [PrismaModule],
+ providers: [BotConfigService, UserService],
+ controllers: [BotConfigController],
+ exports: [BotConfigService],
+})
+export class BotConfigModule {}
diff --git a/apps/api/src/bot-config/bot-config.service.spec.ts b/apps/api/src/bot-config/bot-config.service.spec.ts
new file mode 100644
index 0000000..3e3f6cc
--- /dev/null
+++ b/apps/api/src/bot-config/bot-config.service.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { BotConfigService } from './bot-config.service';
+
+describe('BotConfigService', () => {
+ let service: BotConfigService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [BotConfigService],
+ }).compile();
+
+ service = module.get(BotConfigService);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+});
diff --git a/apps/api/src/bot-config/bot-config.service.ts b/apps/api/src/bot-config/bot-config.service.ts
new file mode 100644
index 0000000..4a8eefc
--- /dev/null
+++ b/apps/api/src/bot-config/bot-config.service.ts
@@ -0,0 +1,78 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { BotConfigDto, CreateBotConfigDto, UpdateBotConfigDto } from './dto/bot-config.dto';
+import { PrismaService } from 'src/prisma/prisma.service';
+
+@Injectable()
+export class BotConfigService {
+ private readonly logger;
+
+ constructor(private readonly prisma: PrismaService) {
+ this.logger = new Logger(BotConfigService.name);
+ }
+
+ /**
+ * Retrieves a bot configuration by its ID
+ * @param id - The unique identifier of the bot configuration
+ * @returns A promise that resolves to the bot configuration
+ * @throws {NotFoundException} If no configuration is found with the given ID
+ */
+ async getBotConfigById(id: string): Promise {
+ this.logger.debug(`Getting bot config for id: ${id}`);
+ const botConfig = await this.prisma.botConfig.findUnique({ where: { id } });
+ this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`);
+ return botConfig;
+ }
+
+ /**
+ * Retrieves a bot configuration by user ID
+ * @param userId - The ID of the user whose configuration to retrieve
+ * @returns A promise that resolves to the bot configuration
+ * @throws {NotFoundException} If no configuration is found for the user
+ */
+ async getBotConfigByUserId(userId: string): Promise {
+ this.logger.debug(`Getting bot config for userId: ${userId}`);
+ const botConfig = await this.prisma.botConfig.findUnique({ where: { userId } });
+ this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`);
+ return botConfig;
+ }
+
+ /**
+ * Creates a new bot configuration for a user
+ * @param userId - The ID of the user to create the configuration for
+ * @param botConfig - The configuration data to create
+ * @returns A promise that resolves to the created bot configuration
+ */
+ async createBotConfig(userId: string, botConfig: CreateBotConfigDto): Promise {
+ this.logger.debug(`Creating bot config for userId: ${userId}`);
+ const newBotConfig = await this.prisma.botConfig.create({ data: { ...botConfig, userId } });
+ this.logger.debug(`New bot config: ${JSON.stringify(newBotConfig)}`);
+ return newBotConfig;
+ }
+
+ /**
+ * Updates an existing bot configuration
+ * @param id - The ID of the bot configuration to update
+ * @param botConfig - The updated configuration data
+ * @returns A promise that resolves to the updated bot configuration
+ * @throws {NotFoundException} If no configuration is found with the given ID
+ */
+ async updateBotConfig(id: string, botConfig: UpdateBotConfigDto): Promise {
+ this.logger.debug(`Updating bot config for id: ${id}`);
+ this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`);
+ const updatedBotConfig = await this.prisma.botConfig.update({ where: { id }, data: botConfig });
+ this.logger.debug(`Updated bot config: ${JSON.stringify(updatedBotConfig)}`);
+ return updatedBotConfig;
+ }
+
+ /**
+ * Deletes a bot configuration
+ * @param userId - The ID of the user whose configuration to delete
+ * @returns A promise that resolves when the configuration is deleted
+ * @throws {NotFoundException} If no configuration is found for the user
+ */
+ async deleteBotConfig(userId: string): Promise {
+ this.logger.debug(`Deleting bot config for userId: ${userId}`);
+ await this.prisma.botConfig.delete({ where: { userId } });
+ this.logger.debug(`Bot config deleted for userId: ${userId}`);
+ }
+}
diff --git a/apps/api/src/bot-config/dto/bot-config.dto.ts b/apps/api/src/bot-config/dto/bot-config.dto.ts
new file mode 100644
index 0000000..966c24b
--- /dev/null
+++ b/apps/api/src/bot-config/dto/bot-config.dto.ts
@@ -0,0 +1,69 @@
+import { BotLevel } from '@prisma/client';
+import { IsUUID, IsEnum, IsString, IsBoolean, IsArray } from 'class-validator';
+import { OmitType, PartialType } from '@nestjs/mapped-types';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class BotConfigDto {
+ @ApiProperty({
+ description: 'Unique identifier for the bot configuration',
+ example: '123e4567-e89b-12d3-a456-426614174000',
+ })
+ @IsUUID()
+ id: string;
+
+ @ApiProperty({
+ description: 'User ID associated with this bot configuration',
+ example: '123e4567-e89b-12d3-a456-426614174001',
+ })
+ @IsUUID()
+ userId: string;
+
+ @ApiProperty({
+ description: 'Level of grumpiness for the bot personality',
+ enum: BotLevel,
+ example: BotLevel.MODERATE,
+ })
+ @IsEnum(BotLevel)
+ grumpinessLevel: BotLevel;
+
+ @ApiProperty({
+ description: 'Level of technical language used by the bot',
+ enum: BotLevel,
+ example: BotLevel.EXTREME,
+ })
+ @IsEnum(BotLevel)
+ technicalityLevel: BotLevel;
+
+ @ApiProperty({
+ description: 'Level of detail in bot responses',
+ enum: BotLevel,
+ example: BotLevel.MODERATE,
+ })
+ @IsEnum(BotLevel)
+ detailLevel: BotLevel;
+
+ @ApiProperty({
+ description: 'Language for bot responses',
+ example: 'en',
+ })
+ @IsString()
+ language: string;
+
+ @ApiProperty({
+ description: 'Whether to automatically approve changes',
+ example: false,
+ })
+ @IsBoolean()
+ autoApprove: boolean;
+
+ @ApiProperty({
+ description: 'File extensions to ignore during code review',
+ example: ['log', 'txt', 'md'],
+ })
+ @IsArray()
+ ignoredExtensions: string[];
+}
+
+export class CreateBotConfigDto extends OmitType(BotConfigDto, ['id']) {}
+
+export class UpdateBotConfigDto extends PartialType(CreateBotConfigDto) {}
diff --git a/apps/api/src/github/dto/github-comment.dto.ts b/apps/api/src/github/dto/github-comment.dto.ts
new file mode 100644
index 0000000..e3879b8
--- /dev/null
+++ b/apps/api/src/github/dto/github-comment.dto.ts
@@ -0,0 +1,67 @@
+export interface GithubCommentDto {
+ body: string;
+ commit_id: string;
+ path: string;
+ line: number;
+}
+
+interface GithubCommentUser {
+ login: string;
+ id: number;
+ node_id: string;
+ avatar_url: string;
+ gravatar_id: string;
+ url: string;
+ html_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ starred_url: string;
+ subscriptions_url: string;
+ organizations_url: string;
+ repos_url: string;
+ events_url: string;
+ received_events_url: string;
+ type: string;
+ site_admin: boolean;
+}
+
+interface GithubCommentLinks {
+ self: {
+ href: string;
+ };
+ html: {
+ href: string;
+ };
+ pull_request: {
+ href: string;
+ };
+}
+
+export interface GithubCommentOutputDto {
+ url: string;
+ pull_request_review_id: number;
+ id: number;
+ node_id: string;
+ diff_hunk: string;
+ path: string;
+ position: number;
+ original_position: number;
+ commit_id: string;
+ original_commit_id: string;
+ in_reply_to_id: number;
+ user: GithubCommentUser;
+ body: string;
+ created_at: string;
+ updated_at: string;
+ html_url: string;
+ pull_request_url: string;
+ author_association: string;
+ _links: GithubCommentLinks;
+ start_line: number;
+ original_start_line: number;
+ start_side: string;
+ line: number;
+ original_line: number;
+ side: string;
+}
diff --git a/apps/api/src/github/dto/github-commit.dto.ts b/apps/api/src/github/dto/github-commit.dto.ts
new file mode 100644
index 0000000..dd9db79
--- /dev/null
+++ b/apps/api/src/github/dto/github-commit.dto.ts
@@ -0,0 +1,68 @@
+export interface GithubCommitAuthor {
+ name: string;
+ email: string;
+ date: string;
+}
+
+export interface GithubCommitTree {
+ sha: string;
+ url: string;
+}
+
+export interface GithubCommitVerification {
+ verified: boolean;
+ reason: string;
+ signature: string;
+ payload: string;
+ verified_at: string;
+}
+
+export interface GithubCommitDetails {
+ author: GithubCommitAuthor;
+ committer: GithubCommitAuthor;
+ message: string;
+ tree: GithubCommitTree;
+ url: string;
+ comment_count: number;
+ verification: GithubCommitVerification;
+}
+
+export interface GithubCommitUser {
+ login: string;
+ id: number;
+ node_id: string;
+ avatar_url: string;
+ gravatar_id: string;
+ url: string;
+ html_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ starred_url: string;
+ subscriptions_url: string;
+ organizations_url: string;
+ repos_url: string;
+ events_url: string;
+ received_events_url: string;
+ type: string;
+ user_view_type: string;
+ site_admin: boolean;
+}
+
+export interface GithubCommitParent {
+ sha: string;
+ url: string;
+ html_url: string;
+}
+
+export interface GithubCommit {
+ sha: string;
+ node_id: string;
+ commit: GithubCommitDetails;
+ url: string;
+ html_url: string;
+ comments_url: string;
+ author: GithubCommitUser;
+ committer: GithubCommitUser;
+ parents: GithubCommitParent[];
+}
diff --git a/apps/api/src/github/dto/github-pull-request-file.dto.ts b/apps/api/src/github/dto/github-pull-request-file.dto.ts
new file mode 100644
index 0000000..e108dfa
--- /dev/null
+++ b/apps/api/src/github/dto/github-pull-request-file.dto.ts
@@ -0,0 +1,21 @@
+export interface PullRequestFileDto {
+ pullRequestNumber: number;
+ pullRequestUrl: string;
+ sha: string;
+ filename: string;
+ status: string;
+ additions: number;
+ deletions: number;
+ changes: number;
+ blob_url: string;
+ raw_url: string;
+ contents_url: string;
+ patch?: string;
+ normalizedPatch?: NormalizedPatchDto[];
+}
+
+export interface NormalizedPatchDto {
+ startLine: number;
+ endLine: number;
+ content: string;
+}
diff --git a/apps/api/src/github/dto/webhook.github.dto.ts b/apps/api/src/github/dto/webhook.github.dto.ts
index 317ab9a..a7f51a3 100644
--- a/apps/api/src/github/dto/webhook.github.dto.ts
+++ b/apps/api/src/github/dto/webhook.github.dto.ts
@@ -195,7 +195,7 @@ export interface GithubReview {
}
export interface GithubInstallation {
- id: string;
+ id: number;
node_id: string;
}
diff --git a/apps/api/src/github/github-api.service.ts b/apps/api/src/github/github-api.service.ts
new file mode 100644
index 0000000..49931ec
--- /dev/null
+++ b/apps/api/src/github/github-api.service.ts
@@ -0,0 +1,271 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import * as jwt from 'jsonwebtoken';
+import axios from 'axios';
+import { PullRequestFileDto, NormalizedPatchDto } from './dto/github-pull-request-file.dto';
+import { GithubCommentDto } from './dto/github-comment.dto';
+import { GithubCommit } from './dto/github-commit.dto';
+@Injectable()
+export class GithubApiService {
+ private readonly logger;
+ private readonly appId: string;
+ private readonly privateKey: string;
+ private token: string;
+
+ constructor(private readonly configService: ConfigService) {
+ this.logger = new Logger(GithubApiService.name);
+ this.appId = this.configService.get('GITHUB_APP_ID');
+ this.privateKey = this.configService.get('GITHUB_PRIVATE_KEY');
+ }
+
+ /**
+ * Generates a JWT token for GitHub App authentication
+ * @returns JWT token string
+ * @private
+ */
+ private generateJwt(): string {
+ this.logger.debug(`Generating JWT token for GitHub App ID: ${this.appId}`);
+
+ const now = Math.floor(Date.now() / 1000);
+
+ return jwt.sign(
+ {
+ iat: now,
+ exp: now + 600,
+ iss: this.appId,
+ },
+ this.privateKey,
+ { algorithm: 'RS256' },
+ );
+ }
+
+ /**
+ * Gets an installation access token for a specific GitHub App installation
+ * @param installationId - The ID of the GitHub App installation
+ * @returns Promise resolving to the installation access token
+ */
+ async getInstallationToken(installationId: number): Promise {
+ this.logger.debug(`Getting installation token for installation ID: ${installationId}`);
+ const jwtToken = this.generateJwt();
+
+ const response = await axios.post(
+ `https://api.github.com/app/installations/${installationId}/access_tokens`,
+ {},
+ {
+ headers: {
+ Authorization: `Bearer ${jwtToken}`,
+ Accept: 'application/vnd.github+json',
+ },
+ },
+ );
+
+ this.token = response.data.token;
+
+ return response.data.token;
+ }
+
+ /**
+ * Retrieves the list of files modified in a pull request
+ * @param token - GitHub access token
+ * @param owner - Repository owner
+ * @param repo - Repository name
+ * @param prNumber - Pull request number
+ * @returns Promise resolving to an array of modified files with their details
+ */
+ async getPullRequestFiles(token: string, owner: string, repo: string, prNumber: number): Promise {
+ this.logger.debug(`Getting pull request files for owner: ${owner}, repo: ${repo}, prNumber: ${prNumber}`);
+ const response = await axios.get(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+
+ const normalizedFiles = await Promise.all(
+ response.data.map(async (file: PullRequestFileDto) => {
+ const normalizedPatch = await this.normalizeDiffPatch(file.patch);
+ return { ...file, normalizedPatch };
+ }),
+ );
+
+ return normalizedFiles;
+ }
+
+ /**
+ * Retrieves the files changed in the last commit of a pull request
+ * @param token - GitHub access token
+ * @param owner - Repository owner
+ * @param repo - Repository name
+ * @param prNumber - Pull request number
+ * @param ignoredExtensions - Array of file extensions to ignore
+ * @returns Promise resolving to an array of modified files with their details
+ * @throws Error if unable to fetch commits or commit details
+ */
+ async getFilesFromLastCommitOfPullRequest(
+ token: string,
+ owner: string,
+ repo: string,
+ prNumber: number,
+ ignoredExtensions: string[],
+ ): Promise<{ pullRequestFiles: PullRequestFileDto[]; commitSha: string }> {
+ this.logger.debug(`Getting files from last commit of pull request ${prNumber} for owner: ${owner}, repo: ${repo}`);
+
+ const headers = {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ };
+
+ const lastPageUrl = await this.getLastPageUrl(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/commits`, headers);
+
+ this.logger.debug(`Last page URL: ${lastPageUrl}`);
+
+ const pullRequestCommits = await axios.get(lastPageUrl, { headers });
+
+ if (pullRequestCommits.status !== 200) {
+ throw new Error('Error getting pull request commits');
+ }
+
+ const commits: GithubCommit[] = pullRequestCommits.data;
+ this.logger.debug(`Pull request commits from last page: ${JSON.stringify(commits)}`);
+
+ const lastCommit = commits[commits.length - 1];
+ const lastCommitSha = lastCommit?.sha;
+ this.logger.debug(`Last commit SHA: ${lastCommitSha}`);
+
+ if (!lastCommitSha) throw new Error('No se encontró el último commit de la PR');
+
+ const commitDetails = await axios.get(`https://api.github.com/repos/${owner}/${repo}/commits/${lastCommitSha}`, { headers });
+
+ if (commitDetails.status !== 200) {
+ throw new Error('Error getting commit details');
+ }
+
+ const files = commitDetails.data.files;
+ this.logger.debug(`Commit details files: ${JSON.stringify(files)}`);
+
+ const filteredFiles = files.filter(file => !ignoredExtensions.includes(file.filename.split('.').pop()));
+ this.logger.debug(`Filtered files: ${JSON.stringify(filteredFiles)}`);
+
+ const normalizedFiles = await Promise.all(
+ filteredFiles.map(async (file: PullRequestFileDto) => {
+ const normalizedPatch = await this.normalizeDiffPatch(file.patch);
+ return { ...file, normalizedPatch };
+ }),
+ );
+
+ return { pullRequestFiles: normalizedFiles, commitSha: lastCommitSha };
+ }
+
+ /**
+ * Gets the URL of the last page from the Link header
+ * @param url - The initial API URL
+ * @param headers - Request headers
+ * @returns Promise resolving to the URL of the last page
+ * @private
+ */
+ private async getLastPageUrl(url: string, headers: Record): Promise {
+ try {
+ const response = await axios.get(url, { headers });
+
+ // Check if there's a Link header
+ const linkHeader = response.headers.link;
+
+ if (!linkHeader) {
+ // If no Link header, this is the only page
+ return url;
+ }
+
+ // Parse the Link header to find the "last" page URL
+ const links = linkHeader.split(',');
+ for (const link of links) {
+ const [urlPart, relPart] = link.split(';');
+ if (relPart.includes('rel="last"')) {
+ // Extract the URL from the Link header format
+ const lastPageUrl = urlPart.trim().replace(/[<>]/g, '');
+ return lastPageUrl;
+ }
+ }
+
+ // If no "last" link is found, return the original URL
+ return url;
+ } catch (error) {
+ this.logger.error(`Error getting last page URL: ${error.message}`);
+ // Return the original URL in case of error
+ return url;
+ }
+ }
+
+ /**
+ * Normalizes a git diff patch into a structured format
+ * @param patch - The raw git diff patch string
+ * @returns Promise resolving to an array of normalized patch lines with line numbers and content
+ */
+ private async normalizeDiffPatch(patch: string): Promise {
+ if (!patch)
+ return {
+ startLine: 0,
+ endLine: 0,
+ content: '',
+ };
+
+ const lines = patch.split('\n');
+ const headerLine = lines.find(line => line.startsWith('@@'));
+
+ if (!headerLine) {
+ throw new Error('No se encontró un encabezado de cambio en el patch');
+ }
+
+ const match = headerLine.match(/@@ -(\d+),\d+ \+(\d+),(\d+) @@/);
+
+ if (!match) {
+ throw new Error('No se pudo extraer la información de las líneas del patch');
+ }
+
+ const startLine = parseInt(match[2], 10);
+ const numLines = parseInt(match[3], 10);
+ const endLine = startLine + numLines - 1;
+
+ const content = lines.slice(lines.indexOf(headerLine) + 1, lines.indexOf(headerLine) + 1 + numLines);
+
+ return {
+ startLine,
+ endLine,
+ content: content.join('\n'),
+ };
+ }
+
+ async postCommentReview(url: string, body: GithubCommentDto, installationId: number) {
+ this.logger.debug(`Posting comment review for pull request ${url}`);
+
+ const token = await this.getInstallationToken(installationId);
+ this.logger.debug(`Token: ${token}`);
+
+ const urlParts = url.split('/');
+ const owner = urlParts[urlParts.length - 4];
+ const repo = urlParts[urlParts.length - 3];
+ const prNumber = urlParts[urlParts.length - 1];
+
+ this.logger.debug(`Extracted owner: ${owner}, repo: ${repo}, prNumber: ${prNumber}`);
+
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`;
+ this.logger.debug(`Using API URL: ${apiUrl}`);
+
+ const payload = {
+ body: body.body.toString().trim(),
+ commit_id: body.commit_id.toString().trim(),
+ path: body.path.toString().trim(),
+ line: body.line,
+ };
+
+ this.logger.debug(`Payload: ${JSON.stringify(payload)}`);
+
+ const response = await axios.post(apiUrl, payload, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ });
+ this.logger.debug(`Comment review posted for pull request ${url}`);
+ return response.data;
+ }
+}
diff --git a/apps/api/src/github/github.controller.spec.ts b/apps/api/src/github/github.controller.spec.ts
index 4adff09..9959b74 100644
--- a/apps/api/src/github/github.controller.spec.ts
+++ b/apps/api/src/github/github.controller.spec.ts
@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
-import { GithubWebhookController } from './github.controller';
+import { GithubController } from './github.controller';
describe('GithubController', () => {
- let controller: GithubWebhookController;
+ let controller: GithubController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
- controllers: [GithubWebhookController],
+ controllers: [GithubController],
}).compile();
- controller = module.get(GithubWebhookController);
+ controller = module.get(GithubController);
});
it('should be defined', () => {
diff --git a/apps/api/src/github/github.controller.ts b/apps/api/src/github/github.controller.ts
index ab447c5..16e5fdc 100644
--- a/apps/api/src/github/github.controller.ts
+++ b/apps/api/src/github/github.controller.ts
@@ -1,17 +1,36 @@
-import { Controller, Post, Body, Logger } from '@nestjs/common';
+import { Controller, Logger, Post, Body } from '@nestjs/common';
import { GithubWebhookDto } from './dto/webhook.github.dto';
-import { RepositoryService } from 'src/repository/repository.service';
import { GithubService } from './github.service';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { Public } from 'src/auth/decorators/public.decorator';
-@Controller('github/webhook')
-export class GithubWebhookController {
+@Public()
+@ApiTags('GitHub')
+@Controller('github')
+export class GithubController {
private readonly logger;
constructor(private readonly githubService: GithubService) {
- this.logger = new Logger(GithubWebhookController.name);
+ this.logger = new Logger(GithubController.name);
}
- @Post()
+ @ApiOperation({
+ summary: 'Handle GitHub webhook events',
+ description: 'Processes incoming GitHub webhook events for pull request and repository actions',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Webhook processed successfully',
+ schema: {
+ properties: {
+ message: {
+ type: 'string',
+ example: 'Webhook received',
+ },
+ },
+ },
+ })
+ @Post('/webhook')
async webhook(@Body() body: GithubWebhookDto) {
this.logger.debug(`Webhook received with action: ${body.action}`);
@@ -28,12 +47,14 @@ export class GithubWebhookController {
this.logger.debug(`Repository edited: ${body.repository.id}`);
break;
case 'submitted':
- this.logger.debug(`Review submitted: ${body.repository.id}`);
+ this.logger.debug(`Review submitted for pull request: ${body.pull_request.id}`);
break;
case 'synchronize':
this.logger.debug(`Pull request synchronized: ${body.pull_request.id}`);
this.githubService.handlePullRequestSynchronized(body);
-
+ break;
+ case 'created':
+ this.logger.debug(`Review created for repository: ${body.repository.id}`);
break;
default:
this.logger.debug(`Unknown action: ${body.action}`);
diff --git a/apps/api/src/github/github.module.ts b/apps/api/src/github/github.module.ts
index 2b9ace5..b1ce72f 100644
--- a/apps/api/src/github/github.module.ts
+++ b/apps/api/src/github/github.module.ts
@@ -1,14 +1,21 @@
import { Module } from '@nestjs/common';
-import { GithubWebhookController } from './github.controller';
+import { GithubController } from './github.controller';
import { GithubService } from './github.service';
+import { GithubApiService } from './github-api.service';
+import { ConfigModule } from '@nestjs/config';
import { RepositoryModule } from 'src/repository/repository.module';
import { PullRequestModule } from 'src/pull-request/pull-request.module';
import { UserModule } from 'src/user/user.module';
+import { ReviewModule } from 'src/review/review.module';
+import { WorkflowModule } from 'src/workflow/workflow.module';
import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper';
+import { GithubMapper } from './mapper/github.mapper';
+import { BotConfigService } from 'src/bot-config/bot-config.service';
+import { BotConfigModule } from 'src/bot-config/bot-config.module';
@Module({
- imports: [RepositoryModule, PullRequestModule, UserModule],
- controllers: [GithubWebhookController],
- providers: [GithubService, PullRequestMapper],
- exports: [GithubService],
+ imports: [ConfigModule, RepositoryModule, PullRequestModule, UserModule, ReviewModule, WorkflowModule, BotConfigModule],
+ controllers: [GithubController],
+ providers: [GithubService, GithubApiService, PullRequestMapper, GithubMapper],
+ exports: [GithubService, GithubApiService],
})
export class GithubModule {}
diff --git a/apps/api/src/github/github.service.ts b/apps/api/src/github/github.service.ts
index c2e6407..9c7daf5 100644
--- a/apps/api/src/github/github.service.ts
+++ b/apps/api/src/github/github.service.ts
@@ -1,18 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { GithubWebhookDto } from './dto/webhook.github.dto';
-import { RepositoryService } from 'src/repository/repository.service';
+import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper';
import { PullRequestService } from 'src/pull-request/pull-request.service';
+import { RepositoryService } from 'src/repository/repository.service';
+import { ReviewService } from 'src/review/review.service';
import { UserService } from 'src/user/user.service';
+import { WorkflowService } from 'src/workflow/workflow.service';
import { RepositoryDto } from 'src/repository/dto/repository.dto';
-import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper';
+import { GithubApiService } from './github-api.service';
+import { PullRequestFileDto } from './dto/github-pull-request-file.dto';
+import { WorkflowMetadata } from 'src/workflow/interface/workflow-metadata.interface';
+import { TriggerWorkflowDto } from 'src/workflow/dto/trigger-workflow.dto';
+import { GithubMapper } from './mapper/github.mapper';
+import { BotConfigService } from 'src/bot-config/bot-config.service';
@Injectable()
export class GithubService {
private readonly logger;
+
constructor(
private readonly repositoryService: RepositoryService,
private readonly pullRequestService: PullRequestService,
private readonly pullRequestMapper: PullRequestMapper,
private readonly userService: UserService,
+ private readonly workflowService: WorkflowService,
+ private readonly githubApiService: GithubApiService,
+ private readonly githubMapper: GithubMapper,
) {
this.logger = new Logger(GithubService.name);
}
@@ -25,7 +37,7 @@ export class GithubService {
async handlePullRequestOpened(webhookDto: GithubWebhookDto) {
this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`);
try {
- const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto);
+ await this.processGitHubWebhook(webhookDto);
} catch (error) {
this.logger.error(`Error handling pull request opened: ${error}`);
}
@@ -39,7 +51,7 @@ export class GithubService {
async handlePullRequestClosed(webhookDto: GithubWebhookDto) {
this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`);
try {
- const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto);
+ await this.processGitHubWebhook(webhookDto);
} catch (error) {
this.logger.error(`Error handling pull request closed: ${error}`);
}
@@ -54,7 +66,7 @@ export class GithubService {
this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`);
try {
- const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto);
+ await this.processGitHubWebhook(webhookDto);
} catch (error) {
this.logger.error(`Error handling pull request synchronized: ${error}`);
}
@@ -72,7 +84,7 @@ export class GithubService {
* 4. Finding or creating the pull request record
* @private
*/
- private async processGitHubWebhook(webhookDto: GithubWebhookDto) {
+ private async preProcessGitHubWebhook(webhookDto: GithubWebhookDto) {
const { repository, pull_request } = webhookDto;
const user = await this.userService.getUserByProviderId(repository.owner.id.toString());
@@ -106,6 +118,59 @@ export class GithubService {
user,
repository: existingRepository,
pullRequest: existingPullRequest,
+ installation: webhookDto.installation,
+ botConfig: user.botConfig,
};
}
+
+ /**
+ * Processes a GitHub webhook event by:
+ * 1. Pre-processing the webhook data to get or create necessary records
+ * 2. Triggering the associated workflow
+ * 3. Returns the processed entities
+ * @param webhookDto The GitHub webhook payload
+ * @returns Object containing the processed user, repository, pull request and review
+ */
+ async processGitHubWebhook(webhookDto: GithubWebhookDto) {
+ this.logger.debug(`Processing GitHub webhook: ${JSON.stringify(webhookDto)}`);
+ try {
+ const { user, repository, pullRequest, installation, botConfig } = await this.preProcessGitHubWebhook(webhookDto);
+
+ const canProceed = await this.workflowService.canProceed(user.subscription, user._count.reviews);
+ this.logger.debug(`Can proceed: ${canProceed}`);
+
+ if (!canProceed) {
+ this.logger.debug(`User has reached the maximum number of credits, skipping workflow`);
+ // TODO: Post a comment to the user to upgrade their subscription (just one time)
+ return { user, repository, pullRequest, workflowResponse: null };
+ }
+
+ const installationToken = await this.githubApiService.getInstallationToken(installation.id);
+
+ const { pullRequestFiles, commitSha } = await this.githubApiService.getFilesFromLastCommitOfPullRequest(
+ installationToken,
+ webhookDto.repository.owner.login,
+ webhookDto.repository.name,
+ webhookDto.pull_request.number,
+ botConfig.ignoredExtensions,
+ );
+ this.logger.debug(`Pull request files: ${JSON.stringify(pullRequestFiles)}`);
+
+ const payload = this.githubMapper.toPullRequestWorkflowPayload(user.id, pullRequestFiles, pullRequest, botConfig, installation.id, commitSha);
+ this.logger.debug(`Payload: ${JSON.stringify(payload)}`);
+
+ if (payload.workflowData.pullRequestFiles.length === 0) {
+ this.logger.debug(`No files to process, skipping workflow`);
+ return { user, repository, pullRequest, workflowResponse: null };
+ }
+
+ const workflowResponse = await this.workflowService.triggerWorkflow(payload);
+ this.logger.debug(`Workflow response: ${JSON.stringify(workflowResponse)}`);
+
+ return { user, repository, pullRequest, workflowResponse };
+ } catch (error) {
+ this.logger.error(`Error processing GitHub webhook: ${error}`);
+ throw error;
+ }
+ }
}
diff --git a/apps/api/src/github/mapper/github.mapper.ts b/apps/api/src/github/mapper/github.mapper.ts
new file mode 100644
index 0000000..e54329d
--- /dev/null
+++ b/apps/api/src/github/mapper/github.mapper.ts
@@ -0,0 +1,52 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { GithubWebhookDto } from '../dto/webhook.github.dto';
+import { TriggerWorkflowDto } from 'src/workflow/dto/trigger-workflow.dto';
+import { v4 as uuidv4 } from 'uuid';
+import { WorkflowMetadata } from 'src/workflow/interface/workflow-metadata.interface';
+import { PullRequestFileDto } from '../dto/github-pull-request-file.dto';
+import { PullRequestDto } from 'src/pull-request/dto/pull-request.dto';
+import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto';
+import { WorkflowSource } from 'src/workflow/enum/workflow-source.enum';
+@Injectable()
+export class GithubMapper {
+ private readonly logger;
+
+ constructor() {
+ this.logger = new Logger(GithubMapper.name);
+ }
+
+ private toWorkflowMetadata(installationId: number, commitSha: string): WorkflowMetadata {
+ this.logger.debug('Mapping workflow metadata');
+ return {
+ requestId: uuidv4(),
+ source: WorkflowSource.GITHUB,
+ installationId: installationId,
+ commitSha: commitSha,
+ };
+ }
+
+ toPullRequestWorkflowPayload(
+ userId: string,
+ files: PullRequestFileDto[],
+ pullRequest: PullRequestDto,
+ botConfig: BotConfigDto,
+ installationId: number,
+ commitSha: string,
+ ): TriggerWorkflowDto {
+ this.logger.debug('Mapping pull request workflow payload');
+ files.map(file => {
+ file.pullRequestNumber = pullRequest.number;
+ file.pullRequestUrl = pullRequest.url;
+ });
+
+ return {
+ workflowMetadata: this.toWorkflowMetadata(installationId, commitSha),
+ workflowData: {
+ userId,
+ pullRequestId: pullRequest.id,
+ pullRequestFiles: files,
+ botConfig,
+ },
+ };
+ }
+}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 50407a5..f1681fb 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -8,8 +8,11 @@ async function bootstrap() {
// Global configuration
app.enableCors();
+ logger.log('CORS enabled');
app.setGlobalPrefix('api/v1');
+ logger.log('Global prefix set to api/v1');
app.useGlobalPipes(new ValidationPipe());
+ logger.log('Validation pipe enabled');
// Swagger configuration
const config = new DocumentBuilder()
@@ -28,17 +31,21 @@ async function bootstrap() {
'access-token',
)
.build();
+ logger.log('Swagger configuration completed');
const document = SwaggerModule.createDocument(app, config);
+ logger.log('Swagger document created');
+
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
customSiteTitle: 'API Documentation',
});
+ logger.log('Swagger setup completed');
// Start server
- await app.listen(process.env.API_PORT ?? 3000);
+ await app.listen(process.env.PORT ?? 3000);
const appUrl = await app.getUrl();
logger.log(`🚀 Server ready at ${appUrl}`);
logger.log(`🚀 API Documentation: ${appUrl}/docs`);
diff --git a/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql b/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql
new file mode 100644
index 0000000..f2d23f7
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Subscription" ADD COLUMN "maxReviews" INTEGER NOT NULL DEFAULT 100;
diff --git a/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql b/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql
new file mode 100644
index 0000000..d9b6e58
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql
@@ -0,0 +1,16 @@
+-- CreateTable
+CREATE TABLE "Review" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "pullRequestId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Review" ADD CONSTRAINT "Review_pullRequestId_fkey" FOREIGN KEY ("pullRequestId") REFERENCES "PullRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql b/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql
new file mode 100644
index 0000000..0455a0c
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Review" ADD COLUMN "comment" TEXT;
diff --git a/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql b/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql
new file mode 100644
index 0000000..7aa850a
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "Review" ADD COLUMN "filename" TEXT,
+ADD COLUMN "patch" TEXT;
diff --git a/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql b/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql
new file mode 100644
index 0000000..040cf5a
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "BotConfig" ADD COLUMN "ignoredExtensions" TEXT[] DEFAULT ARRAY['md', 'txt', 'lock', 'json', 'yml', 'yaml', 'env', 'toml', 'ini', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'mp4', 'webm', 'mp3', 'wav', 'd.ts', 'map', 'snap', 'golden', 'wasm', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'scss', 'sass']::TEXT[];
diff --git a/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql b/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql
new file mode 100644
index 0000000..d2ed812
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "BotConfig" ADD COLUMN "customRules" TEXT;
diff --git a/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql b/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql
new file mode 100644
index 0000000..8526e96
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `maxReviews` on the `Subscription` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Subscription" DROP COLUMN "maxReviews",
+ADD COLUMN "credits" INTEGER NOT NULL DEFAULT 20;
diff --git a/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql b/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql
new file mode 100644
index 0000000..4303b4b
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Review" ADD COLUMN "creditsUsed" INTEGER NOT NULL DEFAULT 1;
diff --git a/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql b/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql
new file mode 100644
index 0000000..23f65ef
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "BotConfig" ADD COLUMN "companyContext" TEXT;
diff --git a/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql b/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql
new file mode 100644
index 0000000..0021e60
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql
@@ -0,0 +1,38 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `companyContext` on the `BotConfig` table. All the data in the column will be lost.
+ - You are about to drop the column `customRules` on the `BotConfig` table. All the data in the column will be lost.
+ - You are about to drop the column `repositoryId` on the `BotConfig` table. All the data in the column will be lost.
+ - You are about to drop the column `botConfigId` on the `Repository` table. All the data in the column will be lost.
+ - You are about to drop the `ReviewFocusArea` table. If the table is not empty, all the data it contains will be lost.
+ - A unique constraint covering the columns `[userId]` on the table `BotConfig` will be added. If there are existing duplicate values, this will fail.
+ - Added the required column `userId` to the `BotConfig` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- DropForeignKey
+ALTER TABLE "BotConfig" DROP CONSTRAINT "BotConfig_repositoryId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "ReviewFocusArea" DROP CONSTRAINT "ReviewFocusArea_botConfigId_fkey";
+
+-- DropIndex
+DROP INDEX "BotConfig_repositoryId_key";
+
+-- AlterTable
+ALTER TABLE "BotConfig" DROP COLUMN "companyContext",
+DROP COLUMN "customRules",
+DROP COLUMN "repositoryId",
+ADD COLUMN "userId" TEXT NOT NULL;
+
+-- AlterTable
+ALTER TABLE "Repository" DROP COLUMN "botConfigId";
+
+-- DropTable
+DROP TABLE "ReviewFocusArea";
+
+-- CreateIndex
+CREATE UNIQUE INDEX "BotConfig_userId_key" ON "BotConfig"("userId");
+
+-- AddForeignKey
+ALTER TABLE "BotConfig" ADD CONSTRAINT "BotConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql b/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql
new file mode 100644
index 0000000..e65c77d
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Review" ADD COLUMN "githubCommentId" INTEGER;
diff --git a/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql b/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql
new file mode 100644
index 0000000..a1d298d
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "ReviewSource" AS ENUM ('GITHUB', 'GITLAB');
+
+-- AlterTable
+ALTER TABLE "Review" ADD COLUMN "source" "ReviewSource";
diff --git a/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql b/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql
new file mode 100644
index 0000000..db1d79e
--- /dev/null
+++ b/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql
@@ -0,0 +1,11 @@
+/*
+ Warnings:
+
+ - A unique constraint covering the columns `[supabaseId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
+
+*/
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "supabaseId" TEXT;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_supabaseId_key" ON "User"("supabaseId");
diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma
index 9c98c95..fdbaabf 100644
--- a/apps/api/src/prisma/schema.prisma
+++ b/apps/api/src/prisma/schema.prisma
@@ -33,14 +33,22 @@ enum PullRequestStatus {
MERGED
}
+enum ReviewSource {
+ GITHUB
+ GITLAB
+}
+
model User {
id String @id @default(uuid())
email String @unique
name String?
+ supabaseId String? @unique
githubId String? @unique
providerId String? @unique
subscription Subscription?
repositories Repository[]
+ reviews Review[]
+ botConfig BotConfig?
onboarded Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -52,8 +60,9 @@ model Subscription {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plan SubscriptionPlan @default(FREE)
status SubscriptionStatus @default(ACTIVE)
+ credits Int @default(20)
startDate DateTime @default(now())
- endDate DateTime?
+ endDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -66,9 +75,6 @@ model Repository {
hasWiki Boolean @default(false)
githubId String? @unique
- botConfigId String?
- botConfig BotConfig?
-
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@ -80,29 +86,18 @@ model Repository {
model BotConfig {
id String @id @default(uuid())
- repositoryId String @unique
- repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
+ userId String @unique
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
grumpinessLevel BotLevel @default(MODERATE)
technicalityLevel BotLevel @default(MODERATE)
detailLevel BotLevel @default(MODERATE)
language String @default("en")
autoApprove Boolean @default(false)
- reviewFocusAreas ReviewFocusArea[]
+ ignoredExtensions String[] @default(["md", "txt", "lock", "json", "yml", "yaml", "env", "toml", "ini", "png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "mp4", "webm", "mp3", "wav", "d.ts", "map", "snap", "golden", "wasm", "woff", "woff2", "ttf", "otf", "eot", "scss", "sass"])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
-model ReviewFocusArea {
- id String @id @default(uuid())
- name String // e.g., "Code Style", "Best Practices", "Documentation"
- botConfigId String
- botConfig BotConfig @relation(fields: [botConfigId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@unique([name, botConfigId])
-}
-
model PullRequest {
id String @id @default(uuid())
title String?
@@ -130,6 +125,7 @@ model PullRequest {
repositoryId String
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
status PullRequestStatus @default(PENDING)
+ reviews Review[]
comments Comment[]
@@ -148,3 +144,19 @@ model Comment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+
+model Review {
+ id String @id @default(uuid())
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ comment String?
+ source ReviewSource?
+ githubCommentId Int?
+ filename String?
+ patch String?
+ creditsUsed Int @default(1)
+ pullRequestId String
+ pullRequest PullRequest @relation(fields: [pullRequestId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
diff --git a/apps/api/src/pull-request/pull-request.controller.ts b/apps/api/src/pull-request/pull-request.controller.ts
index 101e788..1e80e71 100644
--- a/apps/api/src/pull-request/pull-request.controller.ts
+++ b/apps/api/src/pull-request/pull-request.controller.ts
@@ -1,32 +1,88 @@
-import { Controller, Get, Param, Post, Body, Put, Delete, Logger } from '@nestjs/common';
+import { Controller, Get, Param, Post, Body, Put, Delete, Logger, Query } from '@nestjs/common';
import { PullRequestService } from './pull-request.service';
import { PullRequestDto } from './dto/pull-request.dto';
-
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { UserService } from 'src/user/user.service';
+@ApiTags('Pull Requests')
@Controller('pull-request')
export class PullRequestController {
private readonly logger;
- constructor(private readonly pullRequestService: PullRequestService) {
+ constructor(
+ private readonly pullRequestService: PullRequestService,
+ private readonly userService: UserService,
+ ) {
this.logger = new Logger(PullRequestController.name);
}
+ @ApiOperation({
+ summary: 'Get pull request by ID',
+ description: 'Retrieves a pull request using its unique identifier',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Pull request found successfully',
+ })
+ @ApiResponse({ status: 404, description: 'Pull request not found' })
@Get(':id')
async getPullRequestById(@Param('id') id: string) {
this.logger.debug(`Getting pull request by id: ${id}`);
return this.pullRequestService.getPullRequestById(id);
}
+ @ApiOperation({
+ summary: 'Get pull requests by user ID',
+ description: 'Retrieves all pull requests for a specific user',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Pull requests retrieved successfully',
+ })
+ @ApiResponse({ status: 404, description: 'No pull requests found' })
+ @Get('user/:supabaseId')
+ async getPullRequestsBySupabaseId(@Param('supabaseId') supabaseId: string, @Query('page') page: number) {
+ this.logger.debug(`Getting pull requests by user id: ${supabaseId}`);
+ const user = await this.userService.getUserBySupabaseId(supabaseId);
+ return this.pullRequestService.getPullRequestsByRepositories(user.repositories, page);
+ }
+
+ @ApiOperation({
+ summary: 'Create new pull request',
+ description: 'Creates a new pull request with the provided data',
+ })
+ @ApiResponse({
+ status: 201,
+ description: 'Pull request created successfully',
+ })
@Post()
async createPullRequest(@Body() pullRequestDto: PullRequestDto) {
this.logger.debug(`Creating pull request: ${pullRequestDto}`);
return this.pullRequestService.createPullRequest(pullRequestDto, pullRequestDto.repositoryId);
}
+ @ApiOperation({
+ summary: 'Update pull request',
+ description: 'Updates an existing pull request with new data',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Pull request updated successfully',
+ })
+ @ApiResponse({ status: 404, description: 'Pull request not found' })
@Put(':id')
async updatePullRequest(@Param('id') id: string, @Body() pullRequestDto: PullRequestDto) {
this.logger.debug(`Updating pull request: ${pullRequestDto}`);
return this.pullRequestService.updatePullRequest(pullRequestDto);
}
+ @ApiOperation({
+ summary: 'Delete pull request',
+ description: 'Deletes a pull request by its ID',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Pull request deleted successfully',
+ })
+ @ApiResponse({ status: 404, description: 'Pull request not found' })
@Delete(':id')
async deletePullRequest(@Param('id') id: string) {
this.logger.debug(`Deleting pull request: ${id}`);
diff --git a/apps/api/src/pull-request/pull-request.module.ts b/apps/api/src/pull-request/pull-request.module.ts
index c55325f..55dbf23 100644
--- a/apps/api/src/pull-request/pull-request.module.ts
+++ b/apps/api/src/pull-request/pull-request.module.ts
@@ -3,9 +3,11 @@ import { PullRequestService } from './pull-request.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { PullRequestController } from './pull-request.controller';
import { PullRequestMapper } from './mapper/pull-request.mapper';
+import { RepositoryService } from '../repository/repository.service';
+import { UserService } from 'src/user/user.service';
@Module({
imports: [PrismaModule],
- providers: [PullRequestService, PullRequestMapper],
+ providers: [PullRequestService, PullRequestMapper, UserService],
exports: [PullRequestService, PullRequestMapper],
controllers: [PullRequestController],
})
diff --git a/apps/api/src/pull-request/pull-request.service.ts b/apps/api/src/pull-request/pull-request.service.ts
index 970448e..58bbf8c 100644
--- a/apps/api/src/pull-request/pull-request.service.ts
+++ b/apps/api/src/pull-request/pull-request.service.ts
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { PullRequestDto } from './dto/pull-request.dto';
import { CreatePullRequestDto } from './dto/create-pull-request.dto';
+import { Repository } from '@prisma/client';
@Injectable()
export class PullRequestService {
private readonly logger;
@@ -23,6 +24,22 @@ export class PullRequestService {
});
}
+ async getPullRequestsByRepositoryId(repositoryId: string) {
+ this.logger.debug(`Getting pull requests by repository id: ${repositoryId}`);
+ return this.prisma.pullRequest.findMany({
+ where: { repositoryId },
+ });
+ }
+
+ async getPullRequestsByRepositories(repositories: Repository[], page: number) {
+ this.logger.debug(`Getting pull requests by repositories: ${repositories.map(repository => repository.id)}`);
+ return this.prisma.pullRequest.findMany({
+ where: { repositoryId: { in: repositories.map(repository => repository.id) } },
+ skip: (page - 1) * 10,
+ take: 10,
+ });
+ }
+
async createPullRequest(pullRequestDto: CreatePullRequestDto, repositoryId: string) {
this.logger.debug(`Creating pull request: ${pullRequestDto}`);
return this.prisma.pullRequest.create({
diff --git a/apps/api/src/repository/dto/repository.dto.ts b/apps/api/src/repository/dto/repository.dto.ts
index 1243331..e8658c2 100644
--- a/apps/api/src/repository/dto/repository.dto.ts
+++ b/apps/api/src/repository/dto/repository.dto.ts
@@ -1,3 +1,5 @@
+import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto';
+
export interface RepositoryDto {
id?: string;
name: string;
@@ -5,5 +7,5 @@ export interface RepositoryDto {
language?: string;
hasWiki?: boolean;
githubId?: string;
- botConfigId?: string;
+ botConfig?: BotConfigDto;
}
diff --git a/apps/api/src/repository/repository.controller.ts b/apps/api/src/repository/repository.controller.ts
index ed581f9..2b2e853 100644
--- a/apps/api/src/repository/repository.controller.ts
+++ b/apps/api/src/repository/repository.controller.ts
@@ -1,16 +1,28 @@
import { Controller, Logger, Post, Body } from '@nestjs/common';
import { RepositoryService } from './repository.service';
import { RepositoryDto } from './dto/repository.dto';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+@ApiTags('Repository')
@Controller('repository')
export class RepositoryController {
private readonly logger;
+
constructor(private readonly repositoryService: RepositoryService) {
this.logger = new Logger(RepositoryController.name);
}
+ @ApiOperation({
+ summary: 'Create new repository',
+ description: 'Creates a new repository with the provided data and user ID',
+ })
+ @ApiResponse({
+ status: 201,
+ description: 'Repository created successfully',
+ })
@Post()
async createRepository(@Body() repositoryDto: RepositoryDto, @Body() userId: string) {
+ this.logger.debug(`Creating repository: ${JSON.stringify(repositoryDto)}`);
return this.repositoryService.createRepository(repositoryDto, userId);
}
}
diff --git a/apps/api/src/repository/repository.service.ts b/apps/api/src/repository/repository.service.ts
index b2a26fe..d528342 100644
--- a/apps/api/src/repository/repository.service.ts
+++ b/apps/api/src/repository/repository.service.ts
@@ -42,17 +42,9 @@ export class RepositoryService {
id: userId,
},
},
- botConfig: {
- create: {
- grumpinessLevel: BotLevel.MODERATE,
- technicalityLevel: BotLevel.MODERATE,
- detailLevel: BotLevel.MODERATE,
- },
- },
},
include: {
owner: true,
- botConfig: true,
},
});
}
@@ -61,7 +53,13 @@ export class RepositoryService {
this.logger.debug(`Updating repository: ${repository}`);
return await this.prisma.repository.update({
where: { id: repository.id },
- data: repository,
+ data: {
+ name: repository.name,
+ url: repository.url,
+ language: repository.language,
+ hasWiki: repository.hasWiki,
+ githubId: repository.githubId,
+ },
});
}
diff --git a/apps/api/src/review/dto/create-review.dto.ts b/apps/api/src/review/dto/create-review.dto.ts
new file mode 100644
index 0000000..78b621b
--- /dev/null
+++ b/apps/api/src/review/dto/create-review.dto.ts
@@ -0,0 +1,31 @@
+import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator';
+
+export class CreateReviewDto {
+ @IsString()
+ @IsNotEmpty()
+ userId: string;
+
+ @IsString()
+ @IsNotEmpty()
+ pullRequestId: string;
+
+ @IsString()
+ @IsOptional()
+ comment?: string;
+
+ @IsString()
+ @IsOptional()
+ filename?: string;
+
+ @IsString()
+ @IsOptional()
+ patch?: string;
+
+ @IsNumber()
+ @IsOptional()
+ creditsUsed?: number;
+
+ @IsNumber()
+ @IsOptional()
+ githubCommentId?: number;
+}
diff --git a/apps/api/src/review/review.controller.spec.ts b/apps/api/src/review/review.controller.spec.ts
new file mode 100644
index 0000000..b1b0458
--- /dev/null
+++ b/apps/api/src/review/review.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ReviewController } from './review.controller';
+
+describe('ReviewController', () => {
+ let controller: ReviewController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [ReviewController],
+ }).compile();
+
+ controller = module.get(ReviewController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/api/src/review/review.controller.ts b/apps/api/src/review/review.controller.ts
new file mode 100644
index 0000000..f43ae9f
--- /dev/null
+++ b/apps/api/src/review/review.controller.ts
@@ -0,0 +1,47 @@
+import { Body, Controller, Delete, Get, Logger, Param, Post, Put, Query } from '@nestjs/common';
+import { ReviewService } from './review.service';
+import { UserService } from 'src/user/user.service';
+import { CreateReviewDto } from './dto/create-review.dto';
+
+@Controller('review')
+export class ReviewController {
+ private readonly logger;
+
+ constructor(
+ private readonly reviewService: ReviewService,
+ private readonly userService: UserService,
+ ) {
+ this.logger = new Logger(ReviewController.name);
+ }
+
+ @Get('/user/:supabaseId')
+ async getReviewsByUserSupabaseId(@Param('supabaseId') supabaseId: string, @Query('page') page: number) {
+ this.logger.debug(`Getting reviews by user supabase id: ${supabaseId}`);
+ const user = await this.userService.getUserBySupabaseId(supabaseId);
+ return this.reviewService.getReviewsByUserId(user.id, page);
+ }
+
+ @Get('/pull-request/:pullRequestId')
+ async getReviewsByPullRequestId(@Param('pullRequestId') pullRequestId: string) {
+ this.logger.debug(`Getting reviews by pull request id: ${pullRequestId}`);
+ return this.reviewService.getReviewsByPullRequestId(pullRequestId);
+ }
+
+ @Post()
+ async createReview(@Body() review: CreateReviewDto) {
+ this.logger.debug(`Creating review: ${review}`);
+ return this.reviewService.createReview(review);
+ }
+
+ // @Put(':id')
+ // async updateReview(@Param('id') id: string, @Body() review: UpdateReviewDto) {
+ // this.logger.debug(`Updating review: ${review}`);
+ // return this.reviewService.updateReview(id, review);
+ // }
+
+ @Delete(':id')
+ async deleteReview(@Param('id') id: string) {
+ this.logger.debug(`Deleting review: ${id}`);
+ return this.reviewService.deleteReview(id);
+ }
+}
diff --git a/apps/api/src/review/review.module.ts b/apps/api/src/review/review.module.ts
new file mode 100644
index 0000000..87efde5
--- /dev/null
+++ b/apps/api/src/review/review.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { ReviewService } from './review.service';
+import { ReviewController } from './review.controller';
+import { PrismaModule } from 'src/prisma/prisma.module';
+import { UserModule } from 'src/user/user.module';
+@Module({
+ imports: [PrismaModule, UserModule],
+ providers: [ReviewService],
+ controllers: [ReviewController],
+ exports: [ReviewService],
+})
+export class ReviewModule {}
diff --git a/apps/api/src/review/review.service.spec.ts b/apps/api/src/review/review.service.spec.ts
new file mode 100644
index 0000000..b0fc157
--- /dev/null
+++ b/apps/api/src/review/review.service.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ReviewService } from './review.service';
+
+describe('ReviewService', () => {
+ let service: ReviewService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [ReviewService],
+ }).compile();
+
+ service = module.get(ReviewService);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+});
diff --git a/apps/api/src/review/review.service.ts b/apps/api/src/review/review.service.ts
new file mode 100644
index 0000000..a8bab7f
--- /dev/null
+++ b/apps/api/src/review/review.service.ts
@@ -0,0 +1,111 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Review } from '@prisma/client';
+import { PrismaService } from 'src/prisma/prisma.service';
+import { CreateReviewDto } from './dto/create-review.dto';
+@Injectable()
+export class ReviewService {
+ private readonly logger;
+ constructor(private readonly prisma: PrismaService) {
+ this.logger = new Logger(ReviewService.name);
+ }
+
+ /**
+ * Retrieves a review by its ID
+ * @param id - The unique identifier of the review
+ * @returns {Promise} The review if found, null otherwise
+ */
+ async getReviewById(id: string) {
+ this.logger.debug(`Getting review by id: ${id}`);
+ return this.prisma.review.findUnique({
+ where: {
+ id,
+ },
+ });
+ }
+
+ /**
+ * Gets all reviews created by a specific user
+ * @param userId - The unique identifier of the user
+ * @returns {Promise} Array of reviews by the user
+ */
+ async getReviewsByUserId(userId: string, page: number) {
+ this.logger.debug(`Getting reviews by user id: ${userId}`);
+ return this.prisma.review.findMany({
+ where: {
+ userId,
+ },
+ skip: (page - 1) * 10,
+ take: 10,
+ });
+ }
+
+ /**
+ * Gets all reviews created by a specific user
+ * @param userId - The unique identifier of the user
+ * @returns {Promise} Array of reviews by the user
+ */
+ async getAllReviewsByUserId(userId: string) {
+ this.logger.debug(`Getting all reviews by user id: ${userId}`);
+ return this.prisma.review.findMany({
+ where: {
+ userId,
+ },
+ });
+ }
+
+ /**
+ * Gets all reviews for a specific pull request
+ * @param pullRequestId - The unique identifier of the pull request
+ * @returns {Promise} Array of reviews for the pull request
+ */
+ async getReviewsByPullRequestId(pullRequestId: string) {
+ this.logger.debug(`Getting reviews by pull request id: ${pullRequestId}`);
+ return this.prisma.review.findMany({
+ where: {
+ pullRequestId,
+ },
+ });
+ }
+
+ /**
+ * Creates a new review
+ * @param review - The review data to create
+ * @returns {Promise} The created review
+ */
+ async createReview(review: CreateReviewDto) {
+ this.logger.debug(`Creating review: ${JSON.stringify(review)}`);
+ return this.prisma.review.create({
+ data: review,
+ });
+ }
+
+ /**
+ * Updates an existing review
+ * @param id - The unique identifier of the review to update
+ * @param review - The updated review data
+ * @returns {Promise} The updated review
+ */
+ async updateReview(id: string, review: Review) {
+ this.logger.debug(`Updating review: ${JSON.stringify(review)}`);
+ return this.prisma.review.update({
+ where: {
+ id,
+ },
+ data: review,
+ });
+ }
+
+ /**
+ * Deletes a review by its ID
+ * @param id - The unique identifier of the review to delete
+ * @returns {Promise} The deleted review
+ */
+ async deleteReview(id: string) {
+ this.logger.debug(`Deleting review: ${id}`);
+ return this.prisma.review.delete({
+ where: {
+ id,
+ },
+ });
+ }
+}
diff --git a/apps/api/src/subscription/dto/subscription.dto.ts b/apps/api/src/subscription/dto/subscription.dto.ts
new file mode 100644
index 0000000..e51e4bf
--- /dev/null
+++ b/apps/api/src/subscription/dto/subscription.dto.ts
@@ -0,0 +1,33 @@
+import { IsString, IsEnum, IsInt, IsOptional, IsDate } from 'class-validator';
+import { SubscriptionPlan } from '../enum/plans.enum';
+import { SubscriptionStatus } from '../enum/status.enum';
+
+export class SubscriptionDto {
+ @IsString()
+ id: string;
+
+ @IsString()
+ userId: string;
+
+ @IsEnum(SubscriptionPlan)
+ plan: SubscriptionPlan;
+
+ @IsEnum(SubscriptionStatus)
+ status: SubscriptionStatus;
+
+ @IsInt()
+ credits: number;
+
+ @IsDate()
+ startDate: Date;
+
+ @IsOptional()
+ @IsDate()
+ endDate?: Date;
+
+ @IsDate()
+ createdAt: Date;
+
+ @IsDate()
+ updatedAt: Date;
+}
diff --git a/apps/api/src/subscription/enum/credits.enum.ts b/apps/api/src/subscription/enum/credits.enum.ts
new file mode 100644
index 0000000..fd0ce2f
--- /dev/null
+++ b/apps/api/src/subscription/enum/credits.enum.ts
@@ -0,0 +1,5 @@
+export enum SubscriptionCredits {
+ FREE = 20,
+ PRO = 1000,
+ ENTERPRISE = 5000,
+}
diff --git a/apps/api/src/subscription/enum/plans.enum.ts b/apps/api/src/subscription/enum/plans.enum.ts
new file mode 100644
index 0000000..4886330
--- /dev/null
+++ b/apps/api/src/subscription/enum/plans.enum.ts
@@ -0,0 +1,9 @@
+import { SubscriptionPlan as PrismaSubscriptionPlan } from '@prisma/client';
+
+export const SubscriptionPlan = {
+ FREE: PrismaSubscriptionPlan.FREE,
+ PRO: PrismaSubscriptionPlan.PRO,
+ ENTERPRISE: PrismaSubscriptionPlan.ENTERPRISE,
+} as const;
+
+export type SubscriptionPlan = (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan];
diff --git a/apps/api/src/subscription/enum/status.enum.ts b/apps/api/src/subscription/enum/status.enum.ts
new file mode 100644
index 0000000..3a42169
--- /dev/null
+++ b/apps/api/src/subscription/enum/status.enum.ts
@@ -0,0 +1,9 @@
+import { SubscriptionStatus as PrismaSubscriptionStatus } from '@prisma/client';
+
+export const SubscriptionStatus = {
+ ACTIVE: PrismaSubscriptionStatus.ACTIVE,
+ CANCELED: PrismaSubscriptionStatus.CANCELED,
+ EXPIRED: PrismaSubscriptionStatus.EXPIRED,
+} as const;
+
+export type SubscriptionStatus = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus];
diff --git a/apps/api/src/subscription/subscription.controller.spec.ts b/apps/api/src/subscription/subscription.controller.spec.ts
new file mode 100644
index 0000000..9ce1825
--- /dev/null
+++ b/apps/api/src/subscription/subscription.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { SubscriptionController } from './subscription.controller';
+
+describe('SubscriptionController', () => {
+ let controller: SubscriptionController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [SubscriptionController],
+ }).compile();
+
+ controller = module.get(SubscriptionController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/api/src/subscription/subscription.controller.ts b/apps/api/src/subscription/subscription.controller.ts
new file mode 100644
index 0000000..47fca0c
--- /dev/null
+++ b/apps/api/src/subscription/subscription.controller.ts
@@ -0,0 +1,96 @@
+import { Controller, Get, Post, Put, Delete, Param, Body, Logger } from '@nestjs/common';
+import { SubscriptionService } from './subscription.service';
+import { Subscription } from '@prisma/client';
+import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger';
+
+@ApiTags('Subscription')
+@Controller('subscription')
+export class SubscriptionController {
+ private readonly logger;
+
+ constructor(private readonly subscriptionService: SubscriptionService) {
+ this.logger = new Logger(SubscriptionController.name);
+ }
+
+ @ApiOperation({ summary: 'Get subscription by ID' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiResponse({ status: 200, description: 'Returns the subscription' })
+ @Get(':id')
+ async getSubscription(@Param('id') id: string): Promise {
+ this.logger.log(`Getting subscription by ID: ${id}`);
+ return this.subscriptionService.getSubscriptionById(id);
+ }
+
+ @ApiOperation({ summary: 'Get subscription by user ID' })
+ @ApiParam({ name: 'userId', description: 'User ID' })
+ @ApiResponse({ status: 200, description: 'Returns the user subscription' })
+ @Get('user/:userId')
+ async getSubscriptionByUserId(@Param('userId') userId: string): Promise {
+ this.logger.log(`Getting subscription by user ID: ${userId}`);
+ return this.subscriptionService.getSubscriptionByUserId(userId);
+ }
+
+ @ApiOperation({ summary: 'Create a new subscription' })
+ @ApiBody({ description: 'Subscription data' })
+ @ApiResponse({ status: 201, description: 'Subscription created successfully' })
+ @Post()
+ async createSubscription(@Body() subscription: Subscription): Promise {
+ this.logger.log(`Creating subscription: ${JSON.stringify(subscription)}`);
+ return this.subscriptionService.createSubscription(subscription);
+ }
+
+ @ApiOperation({ summary: 'Update subscription by ID' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiBody({ description: 'Updated subscription data' })
+ @ApiResponse({ status: 200, description: 'Subscription updated successfully' })
+ @Put(':id')
+ async updateSubscription(@Param('id') id: string, @Body() subscription: Subscription): Promise {
+ this.logger.log(`Updating subscription: ${id} with data: ${JSON.stringify(subscription)}`);
+ return this.subscriptionService.updateSubscription(id, subscription);
+ }
+
+ @ApiOperation({ summary: 'Delete subscription by ID' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiResponse({ status: 200, description: 'Subscription deleted successfully' })
+ @Delete(':id')
+ async deleteSubscription(@Param('id') id: string): Promise {
+ this.logger.log(`Deleting subscription: ${id}`);
+ return this.subscriptionService.deleteSubscription(id);
+ }
+
+ @ApiOperation({ summary: 'Mark subscription as expired' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiResponse({ status: 200, description: 'Subscription marked as expired' })
+ @Put('expire/:id')
+ async expireSubscription(@Param('id') id: string): Promise {
+ this.logger.log(`Marking subscription as expired: ${id}`);
+ return this.subscriptionService.expireSubscription(id);
+ }
+
+ @ApiOperation({ summary: 'Activate subscription' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiResponse({ status: 200, description: 'Subscription activated successfully' })
+ @Put('activate/:id')
+ async activateSubscription(@Param('id') id: string): Promise {
+ this.logger.log(`Activating subscription: ${id}`);
+ return this.subscriptionService.activateSubscription(id);
+ }
+
+ @ApiOperation({ summary: 'Cancel subscription' })
+ @ApiParam({ name: 'id', description: 'Subscription ID' })
+ @ApiResponse({ status: 200, description: 'Subscription cancelled successfully' })
+ @Put('cancel/:id')
+ async cancelSubscription(@Param('id') id: string): Promise {
+ this.logger.log(`Cancelling subscription: ${id}`);
+ return this.subscriptionService.cancelSubscription(id);
+ }
+
+ @ApiOperation({ summary: 'Get remaining credits for user ID' })
+ @ApiParam({ name: 'supabaseId', description: 'Supabase User ID' })
+ @ApiResponse({ status: 200, description: 'Returns the remaining credits for the user' })
+ @Get('user/:supabaseId/credits-info')
+ async getCreditsInfo(@Param('supabaseId') supabaseId: string): Promise<{ remainingCredits: number; totalCredits: number; usedCredits: number }> {
+ this.logger.log(`Getting credits info for user ID: ${supabaseId}`);
+ return this.subscriptionService.getCreditsInfo(supabaseId);
+ }
+}
diff --git a/apps/api/src/subscription/subscription.module.ts b/apps/api/src/subscription/subscription.module.ts
index 56671bf..2336103 100644
--- a/apps/api/src/subscription/subscription.module.ts
+++ b/apps/api/src/subscription/subscription.module.ts
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SubscriptionService } from './subscription.service';
-
+import { SubscriptionController } from './subscription.controller';
+import { UserService } from '../user/user.service';
+import { ReviewService } from '../review/review.service';
@Module({
imports: [PrismaModule],
- providers: [SubscriptionService],
+ providers: [SubscriptionService, UserService, ReviewService],
exports: [SubscriptionService],
+ controllers: [SubscriptionController],
})
export class SubscriptionModule {}
diff --git a/apps/api/src/subscription/subscription.service.ts b/apps/api/src/subscription/subscription.service.ts
index 9bf44f0..a00eba1 100644
--- a/apps/api/src/subscription/subscription.service.ts
+++ b/apps/api/src/subscription/subscription.service.ts
@@ -1,4 +1,185 @@
-import { Injectable } from '@nestjs/common';
-
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import { Subscription } from '@prisma/client';
+import { SubscriptionCredits } from './enum/credits.enum';
+import { SubscriptionPlan } from './enum/plans.enum';
+import { SubscriptionStatus } from './enum/status.enum';
+import { UserService } from '../user/user.service';
+import { ReviewService } from '../review/review.service';
@Injectable()
-export class SubscriptionService {}
+export class SubscriptionService {
+ private readonly logger;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly userService: UserService,
+ private readonly reviewService: ReviewService,
+ ) {
+ this.logger = new Logger(SubscriptionService.name);
+ }
+
+ /**
+ * Retrieves a subscription by its ID
+ * @param id - The unique identifier of the subscription
+ * @returns {Promise} The subscription if found, null otherwise
+ */
+ async getSubscriptionById(id: string) {
+ this.logger.log(`Getting subscription by ID: ${id}`);
+ return this.prisma.subscription.findUnique({ where: { id } });
+ }
+
+ /**
+ * Gets a subscription by user ID
+ * @param userId - The unique identifier of the user
+ * @returns {Promise} The subscription if found, null otherwise
+ */
+ async getSubscriptionByUserId(userId: string) {
+ this.logger.log(`Getting subscription by user ID: ${userId}`);
+ return this.prisma.subscription.findUnique({ where: { userId } });
+ }
+
+ /**
+ * Creates a new subscription
+ * @param subscription - The subscription data to create
+ * @returns {Promise} The created subscription
+ */
+ async createSubscription(subscription: Subscription) {
+ this.logger.log(`Creating subscription: ${JSON.stringify(subscription)}`);
+ return this.prisma.subscription.create({ data: subscription });
+ }
+
+ /**
+ * Updates an existing subscription
+ * @param id - The unique identifier of the subscription to update
+ * @param subscription - The updated subscription data
+ * @returns {Promise} The updated subscription
+ */
+ async updateSubscription(id: string, subscription: Subscription) {
+ this.logger.log(`Updating subscription: ${id} with data: ${JSON.stringify(subscription)}`);
+ return this.prisma.subscription.update({ where: { id }, data: subscription });
+ }
+
+ /**
+ * Updates a subscription to a free plan
+ * @param id - The unique identifier of the subscription to update
+ * @returns {Promise} The updated subscription
+ */
+ async updateSubscriptionToFree(id: string) {
+ this.logger.log(`Updating subscription: ${id} to free plan`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: {
+ plan: SubscriptionPlan.FREE,
+ credits: SubscriptionCredits.FREE,
+ },
+ });
+ }
+
+ /**
+ * Updates a subscription to a paid plan
+ * @param id - The unique identifier of the subscription to update
+ * @returns {Promise} The updated subscription
+ */
+ async updateSubscriptionToPro(id: string) {
+ this.logger.log(`Updating subscription: ${id} to pro plan`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: {
+ plan: SubscriptionPlan.PRO,
+ credits: SubscriptionCredits.PRO,
+ },
+ });
+ }
+
+ /**
+ * Updates a subscription to an enterprise plan
+ * @param id - The unique identifier of the subscription to update
+ * @returns {Promise} The updated subscription
+ */
+ async updateSubscriptionToEnterprise(id: string) {
+ this.logger.log(`Updating subscription: ${id} to enterprise plan`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: {
+ plan: SubscriptionPlan.ENTERPRISE,
+ credits: SubscriptionCredits.ENTERPRISE,
+ },
+ });
+ }
+
+ /**
+ * Deletes a subscription by its ID
+ * @param id - The unique identifier of the subscription to delete
+ * @returns {Promise} The deleted subscription
+ */
+ async deleteSubscription(id: string) {
+ this.logger.log(`Deleting subscription: ${id}`);
+ return this.prisma.subscription.delete({ where: { id } });
+ }
+
+ /**
+ * Marks a subscription as expired
+ * @param id - The unique identifier of the subscription to expire
+ * @returns {Promise} The updated subscription with expired status
+ */
+ async expireSubscription(id: string) {
+ this.logger.log(`Expiring subscription: ${id}`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: { status: SubscriptionStatus.EXPIRED },
+ });
+ }
+
+ /**
+ * Activates a subscription
+ * @param id - The unique identifier of the subscription to activate
+ * @returns {Promise} The updated subscription with active status
+ */
+ async activateSubscription(id: string) {
+ this.logger.log(`Activating subscription: ${id}`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: { status: SubscriptionStatus.ACTIVE },
+ });
+ }
+
+ /**
+ * Cancels a subscription
+ * @param id - The unique identifier of the subscription to cancel
+ * @returns {Promise} The updated subscription with canceled status
+ */
+ async cancelSubscription(id: string) {
+ this.logger.log(`Cancelling subscription: ${id}`);
+ return this.prisma.subscription.update({
+ where: { id },
+ data: { status: SubscriptionStatus.CANCELED },
+ });
+ }
+
+ /**
+ * Checks if a subscription is active
+ * @param status - The status of the subscription
+ * @returns {Promise} True if the subscription is active, false otherwise
+ */
+ async isSubscriptionActive(status: SubscriptionStatus) {
+ this.logger.log(`Checking subscription status: ${status}`);
+ return status === SubscriptionStatus.ACTIVE;
+ }
+
+ /**
+ * Gets the remaining credits for a user
+ * @param supabaseId - The unique identifier of the user
+ * @returns {Promise} The remaining credits for the user
+ */
+ async getCreditsInfo(supabaseId: string) {
+ this.logger.log(`Getting remaining credits for user: ${supabaseId}`);
+ const user = await this.userService.getUserBySupabaseId(supabaseId);
+ const reviews = await this.reviewService.getAllReviewsByUserId(user.id);
+ const currentCredits = reviews.reduce((acc, review) => acc + review.creditsUsed, 0);
+ return {
+ remainingCredits: user.subscription.credits - currentCredits,
+ totalCredits: user.subscription.credits,
+ usedCredits: currentCredits,
+ };
+ }
+}
diff --git a/apps/api/src/supabase/supabase.service.ts b/apps/api/src/supabase/supabase.service.ts
index dfbb2f4..0d88c55 100644
--- a/apps/api/src/supabase/supabase.service.ts
+++ b/apps/api/src/supabase/supabase.service.ts
@@ -1,11 +1,11 @@
// src/supabase/supabase.service.ts
import { Injectable, Logger } from '@nestjs/common';
-import { createClient } from '@supabase/supabase-js';
+import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SupabaseService {
- private supabase;
+ private readonly supabase: SupabaseClient;
private readonly logger;
private bucketName;
@@ -22,6 +22,11 @@ export class SupabaseService {
}
}
+ // Expose the Supabase client for the auth guard
+ get client(): SupabaseClient {
+ return this.supabase;
+ }
+
/**
* Creates a new user account in Supabase
* @param email - User's email address
@@ -33,7 +38,10 @@ export class SupabaseService {
try {
this.logger.log(`Creating new user with email: ${email}`);
this.logger.debug('Attempting user signup');
- const { user, error } = await this.supabase.auth.signUp({ email, password });
+ const {
+ data: { user },
+ error,
+ } = await this.supabase.auth.signUp({ email, password });
if (error) throw error;
return user;
} catch (error) {
@@ -53,7 +61,10 @@ export class SupabaseService {
try {
this.logger.log(`Attempting login for user: ${email}`);
this.logger.debug('Processing login request');
- const { user, error } = await this.supabase.auth.signInWithPassword({
+ const {
+ data: { user },
+ error,
+ } = await this.supabase.auth.signInWithPassword({
email,
password,
});
@@ -73,7 +84,9 @@ export class SupabaseService {
async getSession() {
try {
this.logger.debug('Retrieving current session');
- const session = this.supabase.auth.session();
+ const {
+ data: { session },
+ } = await this.supabase.auth.getSession();
this.logger.log(session ? 'Session found' : 'No active session');
return session;
} catch (error) {
diff --git a/apps/api/src/user/dto/create-user.dto.ts b/apps/api/src/user/dto/create-user.dto.ts
index be60184..cc559ab 100644
--- a/apps/api/src/user/dto/create-user.dto.ts
+++ b/apps/api/src/user/dto/create-user.dto.ts
@@ -8,10 +8,15 @@ export class CreateUserDto {
name: string;
@IsString()
- githubId: string;
+ @IsOptional()
+ githubId?: string;
+
+ @IsString()
+ @IsOptional()
+ providerId?: string;
@IsString()
- providerId: string;
+ supabaseId: string;
@IsBoolean()
@IsOptional()
diff --git a/apps/api/src/user/user.controller.spec.ts b/apps/api/src/user/user.controller.spec.ts
new file mode 100644
index 0000000..7057a1a
--- /dev/null
+++ b/apps/api/src/user/user.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { UserController } from './user.controller';
+
+describe('UserController', () => {
+ let controller: UserController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [UserController],
+ }).compile();
+
+ controller = module.get(UserController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts
new file mode 100644
index 0000000..e4dcc30
--- /dev/null
+++ b/apps/api/src/user/user.controller.ts
@@ -0,0 +1,66 @@
+import { Controller, Get, Param, Logger, Delete, Put, Body, Post } from '@nestjs/common';
+import { UserService } from './user.service';
+import { User } from '@prisma/client';
+import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger';
+import { CreateUserDto } from './dto/create-user.dto';
+
+@ApiTags('Users')
+@Controller('user')
+export class UserController {
+ private readonly logger;
+
+ constructor(private readonly userService: UserService) {
+ this.logger = new Logger(UserController.name);
+ }
+
+ @ApiOperation({ summary: 'Get user by ID' })
+ @ApiParam({ name: 'id', description: 'User ID' })
+ @ApiResponse({ status: 200, description: 'Returns the user' })
+ @ApiResponse({ status: 404, description: 'User not found' })
+ @Get(':id')
+ async getUserById(@Param('id') id: string) {
+ this.logger.debug(`Getting user by id: ${id}`);
+ return this.userService.getUserById(id);
+ }
+
+ @ApiOperation({ summary: 'Get user by Supabase ID' })
+ @ApiParam({ name: 'supabaseId', description: 'Supabase User ID' })
+ @ApiResponse({ status: 200, description: 'Returns the user with related data' })
+ @ApiResponse({ status: 404, description: 'User not found' })
+ @Get('supabase/:supabaseId')
+ async getUserBySupabaseId(@Param('supabaseId') supabaseId: string) {
+ this.logger.debug(`Getting user by supabase id: ${supabaseId}`);
+ return this.userService.getUserBySupabaseId(supabaseId);
+ }
+
+ @ApiOperation({ summary: 'Create new user' })
+ @ApiBody({ type: CreateUserDto, description: 'User data' })
+ @ApiResponse({ status: 201, description: 'User created successfully' })
+ @ApiResponse({ status: 400, description: 'Invalid user data' })
+ @Post()
+ async createUser(@Body() user: User) {
+ this.logger.debug(`Creating user: ${JSON.stringify(user)}`);
+ return this.userService.createUser(user);
+ }
+
+ @ApiOperation({ summary: 'Update existing user' })
+ @ApiParam({ name: 'id', description: 'User ID to update' })
+ //@ApiBody({ type: User, description: 'Updated user data' })
+ @ApiResponse({ status: 200, description: 'User updated successfully' })
+ @ApiResponse({ status: 404, description: 'User not found' })
+ @Put(':id')
+ async updateUser(@Param('id') id: string, @Body() user: User) {
+ this.logger.debug(`Updating user: ${JSON.stringify(user)}`);
+ return this.userService.updateUser(id, user);
+ }
+
+ @ApiOperation({ summary: 'Delete user' })
+ @ApiParam({ name: 'id', description: 'User ID to delete' })
+ @ApiResponse({ status: 200, description: 'User deleted successfully' })
+ @ApiResponse({ status: 404, description: 'User not found' })
+ @Delete(':id')
+ async deleteUser(@Param('id') id: string) {
+ this.logger.debug(`Deleting user: ${id}`);
+ return this.userService.deleteUser(id);
+ }
+}
diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts
index 6a0a723..433eec0 100644
--- a/apps/api/src/user/user.module.ts
+++ b/apps/api/src/user/user.module.ts
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { PrismaModule } from 'src/prisma/prisma.module';
+import { UserController } from './user.controller';
@Module({
imports: [PrismaModule],
providers: [UserService],
exports: [UserService],
+ controllers: [UserController],
})
export class UserModule {}
diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts
index 4ffeeeb..9eb1d95 100644
--- a/apps/api/src/user/user.service.ts
+++ b/apps/api/src/user/user.service.ts
@@ -1,7 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
-import { User } from '@prisma/client';
+import { BotLevel, User } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
+
+/**
+ * Service responsible for handling user-related operations
+ */
@Injectable()
export class UserService {
private readonly logger;
@@ -10,30 +14,76 @@ export class UserService {
this.logger = new Logger(UserService.name);
}
+ /**
+ * Retrieves a user by their ID
+ * @param id - The unique identifier of the user
+ * @returns The user if found, null otherwise
+ */
async getUserById(id: string) {
this.logger.debug(`Getting user by id: ${id}`);
return this.prismaService.user.findUnique({ where: { id } });
}
+ /**
+ * Retrieves a user by their Supabase ID including related data
+ * @param supabaseId - The Supabase identifier of the user
+ * @returns The user with subscription, repositories, bot config and review count if found, null otherwise
+ */
+ async getUserBySupabaseId(supabaseId: string) {
+ this.logger.debug(`Getting user by supabase id: ${supabaseId}`);
+ return this.prismaService.user.findUnique({
+ where: { supabaseId },
+ include: {
+ subscription: true,
+ repositories: true,
+ botConfig: true,
+ _count: { select: { reviews: true } },
+ },
+ });
+ }
+
+ /**
+ * Retrieves a user by their email address
+ * @param email - The email address of the user
+ * @returns The user if found, null otherwise
+ */
async getUserByEmail(email: string) {
this.logger.debug(`Getting user by email: ${email}`);
return this.prismaService.user.findUnique({ where: { email } });
}
+ /**
+ * Retrieves a user by their GitHub ID
+ * @param githubId - The GitHub identifier of the user
+ * @returns The user if found, null otherwise
+ */
async getUserByGithubId(githubId: string) {
this.logger.debug(`Getting user by github id: ${githubId}`);
return this.prismaService.user.findUnique({ where: { githubId } });
}
+ /**
+ * Retrieves a user by their provider ID including related data
+ * @param providerId - The provider identifier of the user
+ * @returns The user with subscription, bot config and review count if found, null otherwise
+ */
async getUserByProviderId(providerId: string) {
this.logger.debug(`Getting user by provider id: ${providerId}`);
- const user = await this.prismaService.user.findUnique({ where: { providerId } });
+ const user = await this.prismaService.user.findUnique({
+ where: { providerId },
+ include: { subscription: true, botConfig: true, _count: { select: { reviews: true } } },
+ });
this.logger.debug(`User found: ${JSON.stringify(user)}`);
return user;
}
+ /**
+ * Creates a new user with default subscription and bot configuration
+ * @param user - The user data transfer object containing user information
+ * @returns The created user with subscription, bot config, repositories and review count
+ */
async createUser(user: CreateUserDto) {
- this.logger.debug(`Creating user: ${user}`);
+ this.logger.debug(`Creating user: ${JSON.stringify(user)}`);
return this.prismaService.user.create({
data: {
...user,
@@ -44,18 +94,39 @@ export class UserService {
startDate: new Date(),
},
},
+ botConfig: {
+ create: {
+ grumpinessLevel: BotLevel.MODERATE,
+ technicalityLevel: BotLevel.MODERATE,
+ detailLevel: BotLevel.MODERATE,
+ },
+ },
},
include: {
subscription: true,
+ botConfig: true,
+ repositories: true,
+ _count: { select: { reviews: true } },
},
});
}
+ /**
+ * Updates an existing user's information
+ * @param id - The unique identifier of the user to update
+ * @param user - The updated user data
+ * @returns The updated user
+ */
async updateUser(id: string, user: User) {
- this.logger.debug(`Updating user: ${user}`);
+ this.logger.debug(`Updating user: ${JSON.stringify(user)}`);
return this.prismaService.user.update({ where: { id }, data: user });
}
+ /**
+ * Deletes a user from the system
+ * @param id - The unique identifier of the user to delete
+ * @returns The deleted user
+ */
async deleteUser(id: string) {
this.logger.debug(`Deleting user: ${id}`);
return this.prismaService.user.delete({ where: { id } });
diff --git a/apps/api/src/workflow/dto/callback-workflow.dto.ts b/apps/api/src/workflow/dto/callback-workflow.dto.ts
new file mode 100644
index 0000000..fcec46c
--- /dev/null
+++ b/apps/api/src/workflow/dto/callback-workflow.dto.ts
@@ -0,0 +1,51 @@
+import { IsUUID, IsString, IsOptional, IsNumber, IsEnum, IsArray } from 'class-validator';
+import { WorkflowSource } from 'src/workflow/enum/workflow-source.enum';
+export class WorkflowCallbackDto {
+ @IsUUID()
+ userId: string;
+
+ @IsEnum(WorkflowSource)
+ source: WorkflowSource;
+
+ @IsUUID()
+ pullRequestId: string;
+
+ @IsString()
+ @IsOptional()
+ pullRequestUrl?: string;
+
+ @IsArray()
+ @IsOptional()
+ output?: LLMOutputDto[];
+
+ @IsString()
+ @IsOptional()
+ filename?: string;
+
+ @IsString()
+ @IsOptional()
+ patch?: string;
+
+ @IsString()
+ @IsOptional()
+ commitSha?: string;
+
+ @IsNumber()
+ @IsOptional()
+ startLine?: number;
+
+ @IsNumber()
+ @IsOptional()
+ endLine?: number;
+
+ @IsNumber()
+ installationId: number;
+}
+
+export class LLMOutputDto {
+ @IsString()
+ comment?: string;
+
+ @IsNumber()
+ line?: number;
+}
diff --git a/apps/api/src/workflow/dto/trigger-workflow.dto.ts b/apps/api/src/workflow/dto/trigger-workflow.dto.ts
new file mode 100644
index 0000000..5321666
--- /dev/null
+++ b/apps/api/src/workflow/dto/trigger-workflow.dto.ts
@@ -0,0 +1,6 @@
+import { WorkflowMetadata } from '../interface/workflow-metadata.interface';
+import { WorkflowDataInterface } from '../interface/workflow-data-interface';
+export class TriggerWorkflowDto {
+ workflowMetadata?: WorkflowMetadata;
+ workflowData?: WorkflowDataInterface;
+}
diff --git a/apps/api/src/workflow/enum/workflow-source.enum.ts b/apps/api/src/workflow/enum/workflow-source.enum.ts
new file mode 100644
index 0000000..c215d0b
--- /dev/null
+++ b/apps/api/src/workflow/enum/workflow-source.enum.ts
@@ -0,0 +1,4 @@
+export enum WorkflowSource {
+ GITHUB = 'Github',
+ GITLAB = 'Gitlab',
+}
diff --git a/apps/api/src/workflow/interface/workflow-data-interface.ts b/apps/api/src/workflow/interface/workflow-data-interface.ts
new file mode 100644
index 0000000..1194034
--- /dev/null
+++ b/apps/api/src/workflow/interface/workflow-data-interface.ts
@@ -0,0 +1,9 @@
+import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto';
+import { PullRequestFileDto } from 'src/github/dto/github-pull-request-file.dto';
+
+export interface WorkflowDataInterface {
+ userId: string;
+ pullRequestId: string;
+ pullRequestFiles: PullRequestFileDto[];
+ botConfig: BotConfigDto;
+}
diff --git a/apps/api/src/workflow/interface/workflow-metadata.interface.ts b/apps/api/src/workflow/interface/workflow-metadata.interface.ts
new file mode 100644
index 0000000..63ce021
--- /dev/null
+++ b/apps/api/src/workflow/interface/workflow-metadata.interface.ts
@@ -0,0 +1,8 @@
+import { WorkflowSource } from '../enum/workflow-source.enum';
+
+export interface WorkflowMetadata {
+ requestId: string;
+ source: WorkflowSource;
+ installationId: number;
+ commitSha: string;
+}
diff --git a/apps/api/src/workflow/workflow.controller.spec.ts b/apps/api/src/workflow/workflow.controller.spec.ts
new file mode 100644
index 0000000..fb92dde
--- /dev/null
+++ b/apps/api/src/workflow/workflow.controller.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { WorkflowController } from './workflow.controller';
+
+describe('WorkflowController', () => {
+ let controller: WorkflowController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [WorkflowController],
+ }).compile();
+
+ controller = module.get(WorkflowController);
+ });
+
+ it('should be defined', () => {
+ expect(controller).toBeDefined();
+ });
+});
diff --git a/apps/api/src/workflow/workflow.controller.ts b/apps/api/src/workflow/workflow.controller.ts
new file mode 100644
index 0000000..df494f2
--- /dev/null
+++ b/apps/api/src/workflow/workflow.controller.ts
@@ -0,0 +1,53 @@
+import { Controller, Logger, Post, Body, BadRequestException } from '@nestjs/common';
+import { WorkflowService } from './workflow.service';
+import { WorkflowCallbackDto } from './dto/callback-workflow.dto';
+import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { Public } from 'src/auth/decorators/public.decorator';
+
+@Public()
+@ApiTags('Workflow')
+@Controller('workflow')
+export class WorkflowController {
+ private readonly logger;
+
+ constructor(private readonly workflowService: WorkflowService) {
+ this.logger = new Logger(WorkflowController.name);
+ }
+
+ @ApiOperation({
+ summary: 'Handle workflow callback',
+ description: 'Receives and processes a callback from the workflow service with review results',
+ })
+ @ApiResponse({
+ status: 201,
+ description: 'The review has been successfully created',
+ schema: {
+ properties: {
+ review: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' },
+ userId: { type: 'string' },
+ pullRequestId: { type: 'string' },
+ comment: { type: 'string' },
+ filename: { type: 'string' },
+ patch: { type: 'string' },
+ createdAt: { type: 'string', format: 'date-time' },
+ updatedAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ },
+ },
+ })
+ @ApiResponse({ status: 400, description: 'Bad request' })
+ @Post('/review/callback')
+ async callback(@Body() body: WorkflowCallbackDto) {
+ this.logger.debug(`Workflow callback received: ${JSON.stringify(body)}`);
+ try {
+ return this.workflowService.handleWorkflowCallback(body);
+ } catch (error) {
+ this.logger.error(`Error handling workflow callback: ${error}`);
+ throw new BadRequestException('Error handling workflow callback');
+ }
+ }
+}
diff --git a/apps/api/src/workflow/workflow.module.ts b/apps/api/src/workflow/workflow.module.ts
new file mode 100644
index 0000000..62f8dfb
--- /dev/null
+++ b/apps/api/src/workflow/workflow.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { WorkflowService } from './workflow.service';
+import { WorkflowController } from './workflow.controller';
+import { ConfigModule } from '@nestjs/config';
+import { ReviewModule } from 'src/review/review.module';
+import { SubscriptionModule } from 'src/subscription/subscription.module';
+import { GithubApiService } from 'src/github/github-api.service';
+@Module({
+ imports: [ConfigModule, ReviewModule, SubscriptionModule],
+ providers: [WorkflowService, GithubApiService],
+ controllers: [WorkflowController],
+ exports: [WorkflowService],
+})
+export class WorkflowModule {}
diff --git a/apps/api/src/workflow/workflow.service.spec.ts b/apps/api/src/workflow/workflow.service.spec.ts
new file mode 100644
index 0000000..eaf228a
--- /dev/null
+++ b/apps/api/src/workflow/workflow.service.spec.ts
@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { WorkflowService } from './workflow.service';
+
+describe('WorkflowService', () => {
+ let service: WorkflowService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [WorkflowService],
+ }).compile();
+
+ service = module.get(WorkflowService);
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+});
diff --git a/apps/api/src/workflow/workflow.service.ts b/apps/api/src/workflow/workflow.service.ts
new file mode 100644
index 0000000..db0a725
--- /dev/null
+++ b/apps/api/src/workflow/workflow.service.ts
@@ -0,0 +1,105 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { TriggerWorkflowDto } from './dto/trigger-workflow.dto';
+import axios from 'axios';
+import { WorkflowCallbackDto } from './dto/callback-workflow.dto';
+import { ReviewService } from 'src/review/review.service';
+import { SubscriptionDto } from 'src/subscription/dto/subscription.dto';
+import { SubscriptionService } from 'src/subscription/subscription.service';
+import { WorkflowSource } from './enum/workflow-source.enum';
+import { GithubApiService } from 'src/github/github-api.service';
+import { GithubCommentDto, GithubCommentOutputDto } from 'src/github/dto/github-comment.dto';
+@Injectable()
+export class WorkflowService {
+ private readonly logger;
+ private readonly workflowUrl;
+
+ constructor(
+ private readonly configService: ConfigService,
+ private readonly reviewService: ReviewService,
+ private readonly subscriptionService: SubscriptionService,
+ private readonly githubApiService: GithubApiService,
+ ) {
+ this.logger = new Logger(WorkflowService.name);
+ this.workflowUrl = this.configService.get('WORKFLOW_URL');
+ }
+
+ /**
+ * Checks if a workflow can proceed based on subscription status and credits
+ * @param subscription - The subscription data to check
+ * @param currentCredits - The current number of credits used
+ * @returns {Promise} True if the workflow can proceed, false otherwise
+ */
+ async canProceed(subscription: SubscriptionDto, currentCredits: number) {
+ this.logger.log(`Checking subscription status for user: ${subscription.userId}`);
+
+ let canProceed = true;
+
+ const isActive = await this.subscriptionService.isSubscriptionActive(subscription.status);
+
+ if (!isActive) {
+ this.logger.log(`Subscription is not active for user: ${subscription.userId}`);
+ canProceed = false;
+ }
+
+ if (currentCredits >= subscription.credits) {
+ this.logger.log(`User has reached the maximum number of credits for user: ${subscription.userId}`);
+ canProceed = false;
+ }
+
+ return canProceed;
+ }
+
+ /**
+ * Triggers a workflow with the provided data
+ * @param triggerWorkflowDto - The data needed to trigger the workflow
+ * @returns {Promise} The workflow response data
+ */
+ async triggerWorkflow(triggerWorkflowDto: TriggerWorkflowDto) {
+ this.logger.debug(`Triggering workflow: ${JSON.stringify(triggerWorkflowDto)}`);
+ const response = await axios.post(this.workflowUrl, triggerWorkflowDto);
+ return response.data;
+ }
+
+ /**
+ * Handles the callback from a workflow execution
+ * @param body - The workflow callback data
+ * @returns {Promise<{review: any}>} Object containing the created review
+ */
+ async handleWorkflowCallback(body: WorkflowCallbackDto) {
+ this.logger.debug(`Handling workflow callback: ${JSON.stringify(body)}`);
+
+ const reviews = [];
+
+ for (const output of body.output.filter(output => output.line >= 0)) {
+ let githubComment: GithubCommentOutputDto | null = null;
+
+ if (body.source === WorkflowSource.GITHUB) {
+ this.logger.debug('Handling Github workflow callback');
+
+ const githubCommentPayload: GithubCommentDto = {
+ body: output.comment,
+ commit_id: body.commitSha,
+ path: body.filename,
+ line: output.line + body.startLine,
+ };
+ this.logger.debug(`Github comment payload: ${JSON.stringify(githubCommentPayload)}`);
+
+ githubComment = await this.githubApiService.postCommentReview(body.pullRequestUrl, githubCommentPayload, body.installationId);
+ }
+
+ const review = await this.reviewService.createReview({
+ userId: body.userId,
+ pullRequestId: body.pullRequestId,
+ comment: output.comment,
+ filename: body.filename,
+ patch: body.patch,
+ githubCommentId: githubComment?.id,
+ });
+
+ reviews.push(review);
+ }
+
+ return { reviews };
+ }
+}
diff --git a/apps/landing/package.json b/apps/landing/package.json
index ceef770..6acdda9 100644
--- a/apps/landing/package.json
+++ b/apps/landing/package.json
@@ -46,6 +46,7 @@
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
+ "animejs": "^4.0.1",
"astro": "^5.6.0",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
diff --git a/apps/landing/src/components/HowItWorks.astro b/apps/landing/src/components/HowItWorks.astro
index 5a96ba3..9917d71 100644
--- a/apps/landing/src/components/HowItWorks.astro
+++ b/apps/landing/src/components/HowItWorks.astro
@@ -16,22 +16,18 @@
-