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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ LANGCHAIN_PROJECT=cap

# Services Ports
API_GATEWAY_PORT=3000
OAUTH_SERVICE_PORT=3001
PORT=3001
GITHUB_SERVICE_PORT=3002
TEAM_PORT=3003
AI_PORT = 5001
NOTIFICATION_PORT=8085

# GitHub App
GITHUB_APP_ID=
Expand All @@ -20,6 +26,11 @@ GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_WEBHOOK_SECRET=

# MinIO Configuration
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=your_admin_username
MINIO_SECRET_KEY=your_secure_password

# Auth
JWT_SECRET= # min 32 chars, use: openssl rand -hex 32
APP_URL=http://localhost:3000
APP_URL=http://localhost:3000
33 changes: 33 additions & 0 deletions apps/ai-analyzer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.1.0 --activate
WORKDIR /app

FROM base AS builder
COPY . .
RUN pnpm install --no-frozen-lockfile
RUN pnpm run build --filter=@mono/ai-analyzer...

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN corepack enable && corepack prepare pnpm@9.1.0 --activate

RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 expressjs

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/ai-analyzer/package.json ./apps/ai-analyzer/
COPY packages ./packages

RUN pnpm install --prod --no-frozen-lockfile --filter=@mono/ai-analyzer...

COPY --from=builder --chown=expressjs:nodejs /app/apps/ai-analyzer/dist ./apps/ai-analyzer/dist
COPY --from=builder --chown=expressjs:nodejs /app/packages/db/dist ./packages/db/dist
COPY --from=builder --chown=expressjs:nodejs /app/packages/shared/dist ./packages/shared/dist
ENV LANGCHAIN_CALLBACKS_BACKGROUND=true


USER expressjs
EXPOSE 5001

CMD ["node", "apps/ai-analyzer/dist/index.js"]
44 changes: 44 additions & 0 deletions apps/ai-analyzer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@mono/ai-analyzer",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"dev": "tsx watch src/index.ts",
"lint": "eslint src/",
"start": "node dist/index.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@langchain/community": "^1.1.22",
"@langchain/core": "^0.2.0",
"@langchain/google-genai": "^0.0.10",
"@mono/db": "workspace:*",
"@mono/shared": "workspace:*",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.0",
"express": "^4.18.3",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"langchain": "^0.1.0",
"pg": "^8.20.0",
"uuid": "^13.0.0",
"cookie-parser": "^1.4.7",
"zod": "^3.25.76"
},
"devDependencies": {
"typescript": "^5.3.3",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.0",
"@types/pg": "^8.18.0",
"@types/uuid": "^11.0.0",
"@types/cookie-parser": "^1.4.10"
}
}
140 changes: 140 additions & 0 deletions apps/ai-analyzer/src/agents/main-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { PostgresChatMessageHistory } from "@langchain/community/stores/message/postgres";
import { env } from '../config/env.js';
import { z } from "zod";
import { Pool } from "pg";
import { db, findings } from '@mono/db';

const pool = new Pool({
connectionString: env.DATABASE_URL,
});

const parser = StructuredOutputParser.fromZodSchema(
z.object({
language: z.string().describe("The detected programming language (e.g., Python, TypeScript)"),
framework: z.string().describe("The detected framework (e.g., Express, React, Django) or 'None'"),
healthScore: z.number(),
riskLevel: z.enum(["critical", "major", "minor", "info"]),
summaryStats: z.object({
securityCount: z.number(),
performanceCount: z.number(),
memoryCount: z.number(),
styleCount: z.number(),
totalIssues: z.number(),
critical: z.number(),
major: z.number(),
minor: z.number(),
info: z.number(),
executiveSummary: z.string().describe("A 2-sentence overview of the code quality")
}),
detailedFindings: z.array(z.object({
filePath: z.string().describe("The name of the file being analyzed"),
lineStart: z.number().nullable(),
lineEnd: z.number().nullable(),
columnStart: z.number().default(1),
columnEnd: z.number().default(80),
severity: z.enum(["critical", "major", "minor", "info"]),
category: z.enum(["security", "performance", "style", "best_practice", "bug", "maintainability"]),
ruleId: z.string().describe("A unique rule identifier e.g., CAP-SEC-001"),
title: z.string(),
description: z.string(),
suggestion: z.string(),
codeSnippet: z.string(),
suggestedFix: z.string(),
aiConfidence: z.number().min(0).max(1).default(0.95),
aiModel: z.string().default("gemini-2.5-flash")
})),
fixedCode: z.string(),
suggestions: z.array(z.string())
})
);

const model = new ChatGoogleGenerativeAI({
modelName: "gemini-2.5-flash",
apiKey: env.OPENAI_API_KEY,
maxOutputTokens: 8192,
temperature: 0.1,
});

const codeAnalysisPrompt = PromptTemplate.fromTemplate(`
You are the "CAP" (Code Analysis & Protection) Expert.
Your mission is to perform a deep static analysis on the provided code.

DETECTION LAYER TASK:
1. Identify the programming language and framework.
2. If the filePath is "manual_snippet.txt", suggest a more appropriate filename in your mind to guide your analysis.

ANALYSIS GUIDELINES:
- BE PRECISE: Provide exact line numbers (lineStart) for every finding.
- BE ACTIONABLE: The 'suggestedFix' should be ready to copy-paste.
- CATEGORIZE: Every finding must strictly fall into one of the categories: Security, Performance, Memory, Style, or Best Practices.

INSTRUCTIONS:
- The 'analysis' object should be a high-level summary of each category.
- The 'detailedFindings' array must contain every specific bug, leak, or vulnerability as a separate object.
- Classify each finding into: Security, Memory, Performance, or Style.

Session History:
{chat_history}

{format_instructions}

Analyze this file: {filePath}
Content:
{code}
`);

const formatInstructions = parser.getFormatInstructions();

export const analyzeCodeSnippet = async (code: string, sessionId: string, filePath: string = "manual_snippet.txt") => {
try {
const chatHistory = new PostgresChatMessageHistory({
tableName: "code_reviews_history",
sessionId: sessionId,
pool: pool,
});

const previousMessages = await chatHistory.getMessages();
const massiveHistory = previousMessages.slice(-100);

const chatHistoryText = massiveHistory.length > 0
? massiveHistory.map(m => `${m._getType() === 'human' ? 'User' : 'Assistant'}: ${m.content}`).join("\n---\n")
: "Start of session.";

const formattedPrompt = await codeAnalysisPrompt.format({
code: code,
filePath: filePath,
format_instructions: formatInstructions,
chat_history: chatHistoryText || "New analysis session."
});

const response = await model.invoke(formattedPrompt);
let textOutput = response.content as string;

if (textOutput.includes("```")) {
textOutput = textOutput.replace(/```json/g, "").replace(/```/g, "").trim();
}
const analysisResult = await parser.parse(textOutput);

if (analysisResult.detailedFindings && analysisResult.detailedFindings.length > 0) {
const findingsToSave = analysisResult.detailedFindings.map(f => ({
...f,
sessionId: sessionId,
aiGenerated: 1,
}));

await db.insert(findings).values(findingsToSave);
}

await chatHistory.addUserMessage(`Analyzed file: ${filePath}`);
await chatHistory.addAIMessage(textOutput);

return analysisResult;

} catch (error) {
console.error("CAP Analysis Error:", error);
throw new Error("Failed to analyze code. Check logs for details.");
}
};
27 changes: 27 additions & 0 deletions apps/ai-analyzer/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import express, { Application } from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { aiRouter } from './routes/ai.js';
import { healthRouter } from './routes/health.js';
import { errorHandler } from './middleware/error-handler.js';
import helmet from 'helmet';


const app: Application = express();

app.use(cors({
origin: process.env.APP_URL || 'http://localhost:3000',
credentials: true,
}));

app.use(helmet());

app.use(express.json());
app.use(cookieParser());

app.use('/ai', aiRouter);
app.use('/health', healthRouter);

app.use(errorHandler);

export default app;
24 changes: 24 additions & 0 deletions apps/ai-analyzer/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

import dotenv from 'dotenv';
import path from 'path';

dotenv.config({ path: path.resolve(process.cwd(), '../../.env') });

const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
PORT: z.coerce.number().default(3001),
AI_PORT: z.coerce.number().default(5001),
DATABASE_URL: z.string().url(),
GITHUB_CLIENT_ID: z.string().min(1, "GITHUB_CLIENT_ID is required"),
GITHUB_CLIENT_SECRET: z.string().min(1, "GITHUB_CLIENT_SECRET is required"),
JWT_SECRET: z.string().default('a_very_secret_key_change_me_in_production'),
GITHUB_CALLBACK_URL: z.string().url().optional().default('http://localhost:3001/auth/callback/github'),
OPENAI_API_KEY: z.string().min(1, "API Key is required"),
LANGCHAIN_API_KEY: z.string().optional(),
LANGCHAIN_TRACING_V2: z.coerce.boolean().default(false),
});

export const env = envSchema.parse(process.env);

export type Env = z.infer<typeof envSchema>;
11 changes: 11 additions & 0 deletions apps/ai-analyzer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import dotenv from 'dotenv';
dotenv.config();
import app from './app.js';
import { env } from './config/env.js';

const port = env.AI_PORT || 5001;

app.listen(port, () => {
console.info(`ai-analyzer running on http://localhost:${port}`);
console.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
47 changes: 47 additions & 0 deletions apps/ai-analyzer/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
import { HTTP_STATUS } from '@mono/shared';

export interface JwtPayload {
userId: string;
role: string;
githubUsername?: string;
email?: string;
}

declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}

export const isAuth = (req: Request, _res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;

const token = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: req.cookies?.token;

if (!token) {
const error: any = new Error("Missing or invalid Authorization. Please login first.");
error.statusCode = HTTP_STATUS.UNAUTHORIZED;
return next(error);
}
const decoded = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = decoded;

next();
}
catch (err: any) {
console.error("Auth Middleware Error:", err.message);

err.statusCode = HTTP_STATUS.UNAUTHORIZED;
err.message = "Invalid or expired token. Please login again.";

next(err);
}
};
21 changes: 21 additions & 0 deletions apps/ai-analyzer/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { NextFunction, Request, Response } from 'express';

import { errorResponse, HTTP_STATUS } from '@mono/shared';

export interface AppError extends Error {
statusCode?: number;
}

export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction
): void {
const statusCode = err.statusCode || HTTP_STATUS.INTERNAL_SERVER_ERROR;
const message = err.message || 'Internal Server Error';

console.error(`[Error] ${statusCode}: ${message}`, err.stack);

res.status(statusCode).json(errorResponse(message));
}
Loading