diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ace20b..f952c5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,22 @@ jobs: node-version: 20 - name: Install dependencies - run: cd server && npm install + run: cd Server && npm install - name: Generate Prisma client - run: cd server && npx prisma generate + run: cd Server && npx prisma generate + + - name: Update tsconfig lib for ES2022 + run: | + node -e " + const fs = require('fs'); + const path = 'Server/tsconfig.json'; + const tsconfig = JSON.parse(fs.readFileSync(path, 'utf8')); + tsconfig.compilerOptions = tsconfig.compilerOptions || {}; + tsconfig.compilerOptions.lib = ['ES2022', 'DOM']; + tsconfig.compilerOptions.target = 'ES2022'; + fs.writeFileSync(path, JSON.stringify(tsconfig, null, 2)); + " - name: Build TypeScript - run: cd server && npx tsc --noEmit + run: cd Server && npx tsc --noEmit diff --git a/.reviewrc.json b/.reviewrc.json index f24cc9e..815747d 100644 --- a/.reviewrc.json +++ b/.reviewrc.json @@ -14,5 +14,8 @@ ], "performance": [ "avoid nested loops over large arrays" - ] + ], + "ci": { + "install_directory": "backend" + } } \ No newline at end of file diff --git a/Server/app/api-service/controllers/dashboard.controllers.ts b/Server/app/api-service/controllers/dashboard.controllers.ts index 7c29e6f..90fd797 100644 --- a/Server/app/api-service/controllers/dashboard.controllers.ts +++ b/Server/app/api-service/controllers/dashboard.controllers.ts @@ -2,9 +2,7 @@ import { Request, Response } from "express"; import { db } from "../../../package/db/prisma.js"; async function getDbUserId(req: Request): Promise { - // console.log("user Id ------> " , req) const githubId = (req as any).githubUser?.id; - console.log(githubId) if (!githubId) return null; const user = await db.user.findUnique({ where: { githubId } }); return user?.id ?? null; diff --git a/Server/app/api-service/controllers/deployment.controller.ts b/Server/app/api-service/controllers/deployment.controller.ts new file mode 100644 index 0000000..d346da2 --- /dev/null +++ b/Server/app/api-service/controllers/deployment.controller.ts @@ -0,0 +1,29 @@ +import { Request, Response } from "express"; +import { db } from "../../../package/db/prisma.js"; +import { applyDeploymentFix } from "../../worker-service/services/deployment.service.js"; + +export async function getDeployments(req: Request<{ repoId: string }>, res: Response) { + const { repoId } = req.params; + + const deployments = await db.deployment.findMany({ + where: { repositoryId: repoId }, + orderBy: { createdAt: "desc" }, + include: { fixes: true }, + }); + + res.json(deployments); +} + +export async function fixDeployment(req: Request<{ id: string }>, res: Response) { + const { id } = req.params; + + const deployment = await db.deployment.findUnique({ where: { id } }); + + if (deployment?.status !== "failed") { + res.status(400).json({ error: "deployment is not failed" }); + return; + } + + const result = await applyDeploymentFix(id); + res.json(result); +} diff --git a/Server/app/api-service/controllers/repo.controller.ts b/Server/app/api-service/controllers/repo.controller.ts index 60572f1..009058d 100644 --- a/Server/app/api-service/controllers/repo.controller.ts +++ b/Server/app/api-service/controllers/repo.controller.ts @@ -2,9 +2,7 @@ import { Request, Response } from "express"; import { db } from "../../../package/db/prisma.js"; async function getDbUserId(req: Request): Promise { - console.log(req) const githubId = (req as any).githubUser?.id; - console.log("from user Id", githubId) if (!githubId) return null; const user = await db.user.findUnique({ where: { githubId } }); return user?.id ?? null; @@ -12,7 +10,6 @@ async function getDbUserId(req: Request): Promise { export async function allRepos(req: Request, res: Response) { const userId = await getDbUserId(req); - console.log("from get all repo", userId) if (!userId) { res.status(401).json({ error: "unauthorized" }); return; } const repos = await db.repository.findMany({ diff --git a/Server/app/api-service/index.ts b/Server/app/api-service/index.ts index be55eaa..fb58a9d 100644 --- a/Server/app/api-service/index.ts +++ b/Server/app/api-service/index.ts @@ -1,11 +1,12 @@ import "dotenv/config"; -import express, {Request, Response } from "express"; +import express from "express"; import cors from 'cors'; import dashboardRoutes from "./routes/dashboard.routes.js" import repoRoutes from "./routes/repos.route.js" import prRoutes from "./routes/pr.routes.js" import userRoutes from "./routes/user.routes.js" +import deploymentRoutes from "./routes/deployment.routes.js" const app = express() @@ -22,5 +23,6 @@ app.use("/api/dashboard/", dashboardRoutes) app.use("/api/repo/", repoRoutes) app.use("/api/pr/", prRoutes) app.use("/api/user/", userRoutes) +app.use("/api/deployment/", deploymentRoutes) app.listen(3001, () => console.log("API service listening on port 3001")) \ No newline at end of file diff --git a/Server/app/api-service/routes/deployment.routes.ts b/Server/app/api-service/routes/deployment.routes.ts new file mode 100644 index 0000000..2fc404c --- /dev/null +++ b/Server/app/api-service/routes/deployment.routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { getDeployments, fixDeployment } from "../controllers/deployment.controller.js"; +import { requireAuth } from "../auth/auth.js"; + +const router = Router(); + +router.get("/get-deployments/:repoId", requireAuth, getDeployments); +router.post("/fix/:id", requireAuth, fixDeployment); + +export default router; diff --git a/Server/app/webhook-service/middleware/verifySignature.ts b/Server/app/webhook-service/middleware/verifySignature.ts index 4359f7f..1eed6ab 100644 --- a/Server/app/webhook-service/middleware/verifySignature.ts +++ b/Server/app/webhook-service/middleware/verifySignature.ts @@ -3,7 +3,6 @@ import { Request } from "express"; export function verifySignature(req: Request, secret: string): boolean { const signature = req.headers["x-hub-signature-256"]; - console.log("-----------signature is --------- \n", signature) if (!signature || typeof signature !== "string") { return false; } diff --git a/Server/app/webhook-service/routes/github.webhook.ts b/Server/app/webhook-service/routes/github.webhook.ts index 975510b..9550eda 100644 --- a/Server/app/webhook-service/routes/github.webhook.ts +++ b/Server/app/webhook-service/routes/github.webhook.ts @@ -59,22 +59,19 @@ router.post("/webhook/github", async (req: Request, res: Response) => { create: { owner, name, installationId, userId: user.id }, }); } - - console.log(`installation created: ${payload.repositories?.length ?? 0} repos stored`); } } if (event === "pull_request") { + console.log("i am in") const payload = req.body as PullRequestEvent; + console.log(payload) const installationId = payload.installation.id; const owner = payload.repository.owner.login; const repo = payload.repository.name; const prNumber = payload.pull_request.number; - console.log("------------------------------------------ All data i get in terms of repository ------------------------------------------ \n" , payload.repository) - - console.log("pull_request action:", payload.action, `${owner}/${repo}#${prNumber}`); - if (payload.action === "opened") { + if (payload.action === "opened" || payload.action === "synchronize") { const repoOwner = payload.repository.owner; const user = await db.user.upsert({ @@ -102,6 +99,37 @@ router.post("/webhook/github", async (req: Request, res: Response) => { console.log("pushed to queue, queue length:", pushed); } } + + console.log("running the workflowwwwww") + if (event === "workflow_run") { + const payload = req.body as any; + console.log("payload recieved , here is its id", payload.workflow_run.id) + + if (payload.workflow_run?.conclusion === "failure") { + const installationId = payload.installation?.id; + const owner = payload.repository.owner.login; + const repo = payload.repository.name; + const runId = payload.workflow_run.id; + const branch = payload.workflow_run.head_branch; + const prNumber = payload.workflow_run.pull_requests?.[0]?.number ?? null; + + await client.lPush( + "deploymentQueue", + JSON.stringify({ + provider: "github_actions", + owner, + repo, + prNumber, + branch, + deploymentId: String(runId), + runId, + installationId, + }) + ); + + console.log(`workflow_run failure queued: ${owner}/${repo} run ${runId}`); + } + } res.sendStatus(200); }); diff --git a/Server/app/worker-service/index.ts b/Server/app/worker-service/index.ts index 8c1ba24..0c54057 100644 --- a/Server/app/worker-service/index.ts +++ b/Server/app/worker-service/index.ts @@ -1,4 +1,6 @@ import "dotenv/config"; -import {workersOn} from "./workers/review.worker.js" +import { workersOn } from "./workers/review.worker.js"; +import { deploymentWorkerOn } from "./workers/deployment.worker.js"; -workersOn() \ No newline at end of file +workersOn(); +deploymentWorkerOn(); \ No newline at end of file diff --git a/Server/app/worker-service/log-fetchers/github.logs.ts b/Server/app/worker-service/log-fetchers/github.logs.ts new file mode 100644 index 0000000..215272a --- /dev/null +++ b/Server/app/worker-service/log-fetchers/github.logs.ts @@ -0,0 +1,26 @@ +import { getOctokit } from "../../../package/github/client.js"; +import AdmZip from "adm-zip"; + +export async function fetchGithubActionLogs(installationId: number, owner: string, repo: string, runId: number) { + const octokit = await getOctokit(installationId); + + const response = await octokit.rest.actions.downloadWorkflowRunLogs({ + owner, + repo, + run_id: runId, + }); + + const buffer = Buffer.from(response.data as ArrayBuffer); + const zip = new AdmZip(buffer); + const entries = zip.getEntries(); + + let logs = ""; + for (const entry of entries) { + if (!entry.isDirectory) { + logs += `\n--- ${entry.entryName} ---\n`; + logs += zip.readAsText(entry); + } + } + + return logs.slice(0, 20000); +} diff --git a/Server/app/worker-service/processors/deployment.processor.ts b/Server/app/worker-service/processors/deployment.processor.ts new file mode 100644 index 0000000..4a7b35b --- /dev/null +++ b/Server/app/worker-service/processors/deployment.processor.ts @@ -0,0 +1,7 @@ +import { runDeploymentAnalysis, DeploymentJobData } from "../services/deployment.service.js"; + +export async function processDeploymentJob(data: DeploymentJobData) { + console.log(`Processing deployment analysis: ${data.owner}/${data.repo} — ${data.provider}`); + const result = await runDeploymentAnalysis(data); + return result; +} diff --git a/Server/app/worker-service/services/deployment.service.ts b/Server/app/worker-service/services/deployment.service.ts new file mode 100644 index 0000000..b09e642 --- /dev/null +++ b/Server/app/worker-service/services/deployment.service.ts @@ -0,0 +1,146 @@ +import { db } from "../../../package/db/prisma.js"; +import { analyzeDeploymentLogs, generateDeploymentFix } from "../../../package/ai/deployment.js"; +import { fetchGithubActionLogs } from "../log-fetchers/github.logs.js"; +import { getOctokit } from "../../../package/github/client.js"; + +export type DeploymentJobData = { + provider: string; + owner: string; + repo: string; + prNumber?: number; + branch?: string; + deploymentId: string; + runId?: number; + installationId?: number; +}; + +export async function runDeploymentAnalysis(data: DeploymentJobData) { + const { provider, owner, repo, prNumber, branch, deploymentId } = data; + + const repository = await db.repository.findUnique({ + where: { owner_name: { owner, name: repo } }, + }); + + if (!repository) { + console.log(`repo ${owner}/${repo} not found — skipping deployment analysis`); + return; + } + + let logs = ""; + + if (provider === "github_actions" && data.runId && data.installationId) { + logs = await fetchGithubActionLogs(data.installationId, owner, repo, data.runId); + } + + const analysis = await analyzeDeploymentLogs(logs, provider); + + const saved = await db.deployment.create({ + data: { + repositoryId: repository.id, + provider, + status: "failed", + deploymentId, + prNumber: prNumber ?? null, + branch: branch ?? null, + cause: analysis?.cause ?? null, + fix: analysis?.fix ?? null, + rawLogs: logs, + }, + }); + + console.log(`deployment analysis saved for ${owner}/${repo} — ${provider}`); + return saved; +} + +export async function applyDeploymentFix(deploymentDbId: string) { + const deployment = await db.deployment.findUnique({ + where: { id: deploymentDbId }, + include: { repository: true }, + }); + + if (!deployment || !deployment.fix || !deployment.cause) { + throw new Error("deployment not found or has no fix"); + } + + const octokit = await getOctokit(deployment.repository.installationId); + + const savedFix = await db.deploymentFix.create({ + data: { + deploymentId: deploymentDbId, + status: "pending", + }, + }); + + try { + const branch = deployment.branch ?? "main"; + + const branchRef = await octokit.rest.repos.getBranch({ + owner: deployment.repository.owner, + repo: deployment.repository.name, + branch, + }); + + const tree = await octokit.rest.git.getTree({ + owner: deployment.repository.owner, + repo: deployment.repository.name, + tree_sha: branchRef.data.commit.sha, + recursive: "1", + }); + + const filesToFix = tree.data.tree.filter((f) => { + if (!f.path || !f.type) return false; + if (f.type !== "blob") return false; + const ext = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".json", ".yaml", ".yml"]; + return ext.some((e) => f.path!.endsWith(e)); + }); + + let commitSha = null; + let totalDiff = ""; + + for (const file of filesToFix.slice(0, 5)) { + const fileRes = await octokit.rest.repos.getContent({ + owner: deployment.repository.owner, + repo: deployment.repository.name, + path: file.path!, + ref: branch, + }); + + const fileData = fileRes.data as { content: string; sha: string }; + const currentContent = Buffer.from(fileData.content, "base64").toString("utf-8"); + + const result = await generateDeploymentFix(currentContent, deployment.cause!, deployment.fix!, file.path!); + + if (!result || !result.fixedContent || result.fixedContent === currentContent) continue; + + const updateRes = await octokit.rest.repos.createOrUpdateFileContents({ + owner: deployment.repository.owner, + repo: deployment.repository.name, + path: file.path!, + message: `fix: resolve deployment failure — ${deployment.cause!.slice(0, 60)}`, + content: Buffer.from(result.fixedContent).toString("base64"), + sha: fileData.sha, + branch, + }); + + commitSha = updateRes.data.commit.sha ?? null; + totalDiff += `\n--- ${file.path} ---\n${result.explanation}\n`; + } + + await db.deploymentFix.update({ + where: { id: savedFix.id }, + data: { + status: commitSha ? "applied" : "failed", + commitSha, + diff: totalDiff || null, + }, + }); + + return { commitSha, diff: totalDiff }; + } catch (err) { + await db.deploymentFix.update({ + where: { id: savedFix.id }, + data: { status: "failed" }, + }); + throw err; + } +} diff --git a/Server/app/worker-service/services/review.service.ts b/Server/app/worker-service/services/review.service.ts index 280ef1f..fed4ae1 100644 --- a/Server/app/worker-service/services/review.service.ts +++ b/Server/app/worker-service/services/review.service.ts @@ -60,10 +60,7 @@ export async function runPRReview(data: PRReviewJobData) { repo, }); const rules = await getReviewRules(octokit, owner, repo, prNumber); - console.log( - "-------======------======== RULEX =======-------========-------\n", - rules, collaborators - ); + const prompt = reviewPrompt(difference, rules); const aiResponse = await getAIReview(prompt); const cleanText = aiResponse diff --git a/Server/app/worker-service/workers/deployment.worker.ts b/Server/app/worker-service/workers/deployment.worker.ts new file mode 100644 index 0000000..c6a50d9 --- /dev/null +++ b/Server/app/worker-service/workers/deployment.worker.ts @@ -0,0 +1,13 @@ +import { createClient } from "redis"; +import { processDeploymentJob } from "../processors/deployment.processor.js"; + +export async function deploymentWorkerOn() { + const client = createClient(); + await client.connect(); + + while (true) { + const data = await client.brPop("deploymentQueue", 0); + console.log("deployment job popped:", data?.element); + processDeploymentJob(JSON.parse(data!.element)); + } +} diff --git a/Server/app/worker-service/workers/review.worker.ts b/Server/app/worker-service/workers/review.worker.ts index 6c6cd18..26e0b59 100644 --- a/Server/app/worker-service/workers/review.worker.ts +++ b/Server/app/worker-service/workers/review.worker.ts @@ -8,7 +8,6 @@ export async function workersOn(){ while(true){ const gettingData = await client.brPop("reviewQueue", 0) - console.log("here is the popped data =======>", gettingData?.element) processPRReviewJob(JSON.parse(gettingData!.element)) } } \ No newline at end of file diff --git a/Server/package-lock.json b/Server/package-lock.json index c2e0aee..5a9216f 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -16,6 +16,7 @@ "@octokit/webhooks-types": "^7.6.1", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", + "adm-zip": "^0.5.17", "axios": "^1.14.0", "body-parser": "^2.2.2", "cors": "^2.8.6", @@ -27,6 +28,7 @@ "redis": "^5.11.0" }, "devDependencies": { + "@types/adm-zip": "^0.5.8", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.5.0", @@ -2059,6 +2061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/aws-lambda": { "version": "8.10.161", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.161.tgz", @@ -2268,6 +2280,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", diff --git a/Server/package.json b/Server/package.json index ae441c6..391ab35 100644 --- a/Server/package.json +++ b/Server/package.json @@ -21,6 +21,7 @@ "@octokit/webhooks-types": "^7.6.1", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", + "adm-zip": "^0.5.17", "axios": "^1.14.0", "body-parser": "^2.2.2", "cors": "^2.8.6", @@ -32,6 +33,7 @@ "redis": "^5.11.0" }, "devDependencies": { + "@types/adm-zip": "^0.5.8", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.5.0", diff --git a/Server/package/ai/deployment.ts b/Server/package/ai/deployment.ts new file mode 100644 index 0000000..edd2849 --- /dev/null +++ b/Server/package/ai/deployment.ts @@ -0,0 +1,77 @@ +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY!, +}); + +export async function analyzeDeploymentLogs(logs: string, provider: string) { + const res = await anthropic.messages.create({ + model: "claude-sonnet-4-6", + max_tokens: 1500, + system: `You are a senior DevOps engineer analyzing CI/CD deployment failure logs. + +Given raw build/deployment logs from ${provider}, identify the root cause of the failure. + +Return ONLY valid JSON in this exact format: + +{ + "cause": "short explanation of why it failed", + "file": "the file that caused the failure if identifiable, otherwise null", + "line": line number if identifiable otherwise null, + "fix": "specific actionable fix to resolve the failure" +} + +Do NOT include markdown or text outside the JSON.`, + messages: [ + { + role: "user", + content: `Here are the deployment logs:\n\n${logs}`, + }, + ], + }); + + const block = res.content.find((b) => b.type === "text"); + if (!block || block.type !== "text") throw new Error("No text block in AI response"); + + const clean = block.text.replace(/```json/g, "").replace(/```/g, "").trim(); + + try { + return JSON.parse(clean); + } catch { + return null; + } +} + +export async function generateDeploymentFix(fileContent: string, cause: string, fix: string, fileName: string) { + const res = await anthropic.messages.create({ + model: "claude-sonnet-4-6", + max_tokens: 4000, + system: `You are a senior software engineer. You will be given a file and a description of a bug causing a deployment failure. Apply the fix and return the complete corrected file content. + +Return ONLY valid JSON in this exact format: + +{ + "fixedContent": "the complete corrected file content as a string", + "explanation": "one sentence explaining what you changed" +} + +Do NOT include markdown or text outside the JSON.`, + messages: [ + { + role: "user", + content: `File: ${fileName}\n\nCause: ${cause}\n\nFix to apply: ${fix}\n\nFile content:\n${fileContent}`, + }, + ], + }); + + const block = res.content.find((b) => b.type === "text"); + if (!block || block.type !== "text") throw new Error("No text block in AI response"); + + const clean = block.text.replace(/```json/g, "").replace(/```/g, "").trim(); + + try { + return JSON.parse(clean); + } catch { + return null; + } +} diff --git a/Server/prisma/migrations/20260412025533_add_deployments/migration.sql b/Server/prisma/migrations/20260412025533_add_deployments/migration.sql new file mode 100644 index 0000000..0c665eb --- /dev/null +++ b/Server/prisma/migrations/20260412025533_add_deployments/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "Deployment" ( + "id" TEXT NOT NULL, + "repositoryId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "status" TEXT NOT NULL, + "deploymentId" TEXT NOT NULL, + "prNumber" INTEGER, + "branch" TEXT, + "cause" TEXT, + "fix" TEXT, + "rawLogs" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentFix" ( + "id" TEXT NOT NULL, + "deploymentId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "commitSha" TEXT, + "diff" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DeploymentFix_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentFix" ADD CONSTRAINT "DeploymentFix_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/Server/prisma/schema.prisma b/Server/prisma/schema.prisma index c30793c..1b113c8 100644 --- a/Server/prisma/schema.prisma +++ b/Server/prisma/schema.prisma @@ -31,6 +31,7 @@ model Repository { userId String user User @relation(fields: [userId], references: [id]) reviews PRReview[] + deployments Deployment[] createdAt DateTime @default(now()) @@unique([owner, name]) @@ -61,11 +62,43 @@ model Issue { file String line Int - category String - severity String + category String + severity String problem String fix String createdAt DateTime @default(now()) +} + +model Deployment { + id String @id @default(cuid()) + repositoryId String + repository Repository @relation(fields: [repositoryId], references: [id]) + + provider String + status String + deploymentId String + prNumber Int? + branch String? + + cause String? + fix String? + rawLogs String? + + fixes DeploymentFix[] + + createdAt DateTime @default(now()) +} + +model DeploymentFix { + id String @id @default(cuid()) + deploymentId String + deployment Deployment @relation(fields: [deploymentId], references: [id]) + + status String + commitSha String? + diff String? + + createdAt DateTime @default(now()) } \ No newline at end of file diff --git a/client/src/app/(dashboard)/repo/[owner]/[repo]/page.tsx b/client/src/app/(dashboard)/repo/[owner]/[repo]/page.tsx index 5734b95..e42908c 100644 --- a/client/src/app/(dashboard)/repo/[owner]/[repo]/page.tsx +++ b/client/src/app/(dashboard)/repo/[owner]/[repo]/page.tsx @@ -19,6 +19,8 @@ import { Topbar } from '@/components/layout/Topbar' import { useRepos } from '@/hooks/useRepos' import { useAllPRs } from '@/hooks/useAllPRs' import { useCollaborators } from '@/hooks/useCollaborator' +import { useDeployments, Deployment } from '@/hooks/useDeployments' +import { fixDeployment } from '@/lib/api/deployments' function timeAgo(dateStr: string) { const diff = Date.now() - new Date(dateStr).getTime() @@ -40,12 +42,13 @@ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: ) } -type TabId = 'reviews' | 'analytics' | 'collaborators' | 'settings' +type TabId = 'reviews' | 'analytics' | 'collaborators' | 'settings' | 'deployment' const tabs: { id: TabId; label: string }[] = [ { id: 'reviews', label: 'Reviews' }, { id: 'analytics', label: 'Analytics' }, { id: 'collaborators', label: 'Collaborators' }, + {id: 'deployment', label: 'deployment'}, { id: 'settings', label: 'Settings' }, ] @@ -89,6 +92,8 @@ export default function RepoPage() { const { prs, loading } = useAllPRs(repoId) const { allCollaborators } = useCollaborators(repoId) + const { deployments, loading: deploymentsLoading, setDeployments } = useDeployments(repoId) + const [fixingId, setFixingId] = useState(null) const reviewsData = useMemo(() => { const counts: Record = {} @@ -103,6 +108,24 @@ export default function RepoPage() { setCheckedAreas((prev) => prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]) } + async function handleFix(deployment: Deployment) { + setFixingId(deployment.id) + try { + const result = await fixDeployment(deployment.id) + setDeployments((prev) => + prev.map((d) => + d.id === deployment.id + ? { ...d, fixes: [...d.fixes, { id: Date.now().toString(), status: 'applied', commitSha: result.commitSha, diff: result.diff, createdAt: new Date().toISOString() }] } + : d + ) + ) + } catch { + alert('Failed to apply fix') + } finally { + setFixingId(null) + } + } + return ( <> @@ -215,6 +238,62 @@ export default function RepoPage() { )} + {activeTab === 'deployment' && ( +
+ {deploymentsLoading &&

Loading...

} + {!deploymentsLoading && deployments.length === 0 && ( +

No deployments tracked yet.

+ )} + {deployments.map((d) => { + const appliedFix = d.fixes.find((f) => f.status === 'applied') + const isFixing = fixingId === d.id + return ( + +
+
+ +
+
+ {d.provider.replace('_', ' ')} + {d.branch && {d.branch}} + {d.prNumber && PR #{d.prNumber}} +
+

{new Date(d.createdAt).toLocaleString()}

+
+
+
+ {d.status === 'failed' && !appliedFix && ( + + )} + {appliedFix && appliedFix.commitSha && ( + Fixed · {appliedFix.commitSha.slice(0, 7)} + )} +
+
+ {d.cause && ( +
+

Cause

+

{d.cause}

+
+ )} + {d.fix && ( +
+

Suggested fix

+

{d.fix}

+
+ )} +
+ ) + })} +
+ )} + {activeTab === 'settings' && (
Review strictness diff --git a/client/src/hooks/useDeployments.ts b/client/src/hooks/useDeployments.ts new file mode 100644 index 0000000..4155ce7 --- /dev/null +++ b/client/src/hooks/useDeployments.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from "react"; +import { fetchDeployments } from "@/lib/api/deployments"; + +export type DeploymentFix = { + id: string; + status: string; + commitSha: string | null; + diff: string | null; + createdAt: string; +}; + +export type Deployment = { + id: string; + provider: string; + status: string; + deploymentId: string; + prNumber: number | null; + branch: string | null; + cause: string | null; + fix: string | null; + createdAt: string; + fixes: DeploymentFix[]; +}; + +export function useDeployments(repoId: string) { + const [deployments, setDeployments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!repoId) return; + fetchDeployments(repoId) + .then(setDeployments) + .catch(() => setError("Failed to load deployments")) + .finally(() => setLoading(false)); + }, [repoId]); + + return { deployments, loading, error, setDeployments }; +} diff --git a/client/src/lib/api/deployments.ts b/client/src/lib/api/deployments.ts new file mode 100644 index 0000000..da5a742 --- /dev/null +++ b/client/src/lib/api/deployments.ts @@ -0,0 +1,11 @@ +import apiClient from "./client"; + +export async function fetchDeployments(repoId: string) { + const { data } = await apiClient.get(`/api/deployment/get-deployments/${repoId}`); + return data; +} + +export async function fixDeployment(deploymentId: string) { + const { data } = await apiClient.post(`/api/deployment/fix/${deploymentId}`); + return data; +}