Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions app/analyze/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
memo,
} from "react";
import { useSearchParams, useRouter } from "next/navigation";
import ArchInsightsPanel from "@/components/analyze/ArchInsightsPanel";
import { AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { ErrorBoundary } from "@/components/ui/ErrorBoundary";
Expand Down Expand Up @@ -215,6 +216,14 @@ const AnalyzeContent = memo(() => {
)}

{activeTab === "risk_radar" && <RiskRadarPanel data={data} />}
{activeTab === "arch_insights" && (
<ErrorBoundary
key="arch-insights-boundary"
fallbackMessage="Failed to load architecture insights."
>
<ArchInsightsPanel data={data} />
</ErrorBoundary>
)}
</AnimatePresence>
</div>
</div>
Expand Down
34 changes: 21 additions & 13 deletions app/api/analyze-pr/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,23 @@ export async function POST(req: Request) {
if (authError || !user)
return NextResponse.json({ error: "Unauthorized. Please log in." }, { status: 401 });

const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email);
if (!isUnderLimit)
return NextResponse.json(
{ error: "RATE_LIMIT_REACHED", message: "Daily limit of 10 scans reached. Please upgrade to the Architect tier to continue." },
{ status: 429 },
);
// Check pro status — pro users skip the usage limit entirely
const { data: profile } = await supabase
.from("profiles")
.select("plan_tier")
.eq("id", user.id)
.single();

const isPro = profile?.plan_tier === "pro";

if (!isPro) {
const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email);
if (!isUnderLimit)
return NextResponse.json(
{ error: "RATE_LIMIT_REACHED", message: "Daily limit of 10 scans reached. Please upgrade to the Specialist tier to continue." },
{ status: 429 },
);
}

// ── PR metadata ───────────────────────────────────────────────────────────
const prMetaRes = await fetch(
Expand All @@ -57,14 +68,11 @@ export async function POST(req: Request) {
const prFiles = await prFilesRes.json();

// ── Extract changed filenames for the client-side graph BFS ──────────────
// This is the only addition to the original route: we collect the raw
// filename list and attach it to the final response so ArchitectureMap
// can run the reverse BFS entirely on the client without a second API call.
const changedFiles: string[] = Array.isArray(prFiles)
? prFiles.map((f: { filename: string }) => f.filename)
: [];

// ── Secret scanning + author fetch (unchanged) ────────────────────────────
// ── Secret scanning + author fetch ────────────────────────────────────────
const securityAlerts: string[] = [];

const filePromises = prFiles.slice(0, 15).map(
Expand All @@ -82,7 +90,7 @@ export async function POST(req: Request) {
const fileChangesArray = await Promise.all(filePromises);
const fileChangesText = fileChangesArray.join("\n\n");

// ── Groq prompt (unchanged) ───────────────────────────────────────────────
// ── Groq prompt ───────────────────────────────────────────────────────────
const systemPrompt = `You are a senior software engineer conducting a strict code review on a Pull Request.
Repository: ${owner}/${repo}
PR Title: ${prMeta.title}
Expand Down Expand Up @@ -127,7 +135,7 @@ Analyze these code changes and return ONLY a valid JSON object with EXACTLY this
const finalResult = JSON.parse(data.choices[0].message.content);
console.log("🤖 RAW AI PAYLOAD:", JSON.stringify(finalResult.suggestedReviewers, null, 2));

// ── Security alert injection (unchanged) ──────────────────────────────────
// ── Security alert injection ──────────────────────────────────────────────
if (securityAlerts.length > 0) {
finalResult.riskLevel = "high";
if (!finalResult.blastRadius) finalResult.blastRadius = [];
Expand All @@ -139,7 +147,7 @@ Analyze these code changes and return ONLY a valid JSON object with EXACTLY this
// ── Attach raw changed files for client-side graph BFS ────────────────────
finalResult.changedFiles = changedFiles;

// ── Persist to Supabase (unchanged) ───────────────────────────────────────
// ── Persist to Supabase ───────────────────────────────────────────────────
const { error: dbError } = await supabase.from("pr_analyses").insert({
user_id: user.id,
repo_name: `${owner}/${repo}`,
Expand Down
70 changes: 19 additions & 51 deletions app/api/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
/**
* app/api/analyze/route.ts
*
* Network layer only: Auth, Rate-limiting, DB cache, Streaming.
* All heavy lifting is delegated to `runAstPipeline`.
*/

export const runtime = "nodejs";

import { NextRequest, NextResponse } from "next/server";
import { ratelimitAuth, ratelimitFree, ratelimitPro } from "@/lib/ratelimit";
import { cookies, headers } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { ratelimitAuth, ratelimitFree } from "@/lib/ratelimit";
import { checkUsageLimit } from "@/lib/usage";
import { processAndStoreCodebase } from "@/lib/rag";
import {
Expand All @@ -23,16 +20,6 @@ import { GitHubAuthError } from "@/lib/github";
const ANALYSIS_VERSION = 10;

// ── SafeStream ────────────────────────────────────────────────────────────────
/**
* Guards the ReadableStreamDefaultController against two classes of bugs
* that crash Vercel serverless functions in production:
*
* 1. `enqueue()` after `close()` — throws "Controller is already closed".
* 2. `close()` called twice — same error.
*
* The `keepAlive` interval is always cleared through this class, making it
* impossible to forget a clearInterval in any code path.
*/
class SafeStream {
private closed = false;

Expand All @@ -42,38 +29,25 @@ class SafeStream {
private readonly keepAlive: ReturnType<typeof setInterval>,
) {}

/**
* Serialises `payload` as JSON and enqueues it.
* If the stream is already closed (or the client disconnected), the error is
* absorbed and `close()` is called to clean up the interval immediately.
*/
send(payload: object): void {
if (this.closed) return;
try {
this.ctrl.enqueue(this.enc.encode(JSON.stringify(payload)));
} catch {
// Client disconnected or stream was cancelled — clean up now rather
// than waiting for the next keep-alive tick.
this.close();
}
}

/**
* Clears the keep-alive interval and closes the stream.
* Safe to call multiple times.
*/
close(): void {
if (this.closed) return;
this.closed = true;
clearInterval(this.keepAlive);
try {
this.ctrl.close();
} catch {
// Already closed or errored — nothing to do.
}
}

/** Convenience: send an error envelope and close the stream. */
sendError(error: string, extra?: Record<string, unknown>): void {
this.send({ error, ...extra });
this.close();
Expand All @@ -83,8 +57,6 @@ class SafeStream {
// ── Route Handler ─────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
// ── 1. Body parsing ────────────────────────────────────────────────────────
// `req.json()` throws on malformed JSON or an empty body. Handle it before
// opening the stream so we can return a clean 400 response.
let body: { repoUrl?: unknown; isLocal?: unknown; localFiles?: unknown };
try {
body = await req.json();
Expand All @@ -97,8 +69,6 @@ export async function POST(req: NextRequest) {
const localFiles = Array.isArray(body.localFiles) ? body.localFiles : undefined;

// ── 2. Input validation ────────────────────────────────────────────────────
// Validate before touching Supabase / Upstash to avoid wasting quota on
// obviously bad requests.
if (!isLocal && !repoUrl) {
return NextResponse.json(
{ error: "repoUrl is required for non-local analysis." },
Expand Down Expand Up @@ -150,6 +120,7 @@ export async function POST(req: NextRequest) {
let session = null;
let authUser = null;
let isAuthor = false;
let isPro = false;

try {
const { data: { session: s } } = await supabase.auth.getSession();
Expand All @@ -162,6 +133,15 @@ export async function POST(req: NextRequest) {
isAuthor = user.email === process.env.AUTHOR_EMAIL;

if (!isAuthor) {
// Check pro status
const { data: profile } = await supabase
.from("profiles")
.select("plan_tier")
.eq("id", user.id)
.single();

isPro = profile?.plan_tier === "pro";

const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email);
if (!isUnderLimit) {
return NextResponse.json(
Expand All @@ -173,8 +153,6 @@ export async function POST(req: NextRequest) {
}
}
} catch (err) {
// Auth failure is non-fatal — demote to anonymous. The subsequent
// rate-limit check will use the IP instead.
console.error("[analyze] Auth setup error:", err);
}

Expand All @@ -183,7 +161,14 @@ export async function POST(req: NextRequest) {
// ── 4. Rate limiting ───────────────────────────────────────────────────────
if (!isAuthor) {
const identifier = userId ?? ip;
const limiter = userId ? ratelimitAuth : ratelimitFree;

// Pro users get a higher Upstash bucket, anon users get IP-based free bucket
const limiter = !userId
? ratelimitFree
: isPro
? ratelimitPro
: ratelimitAuth;

const { success, limit, reset, remaining } = await limiter.limit(identifier);

if (!success) {
Expand All @@ -210,10 +195,6 @@ export async function POST(req: NextRequest) {

const stream = new ReadableStream({
async start(ctrl) {
// The keep-alive ping enqueues raw whitespace directly on `ctrl`.
// This is intentional: SafeStream JSON-encodes its payloads, which
// would produce `{}` and could confuse single-value JSON parsers.
// If enqueue fails (stream closed/cancelled), the interval clears itself.
const keepAlive = setInterval(() => {
try {
ctrl.enqueue(encoder.encode(" "));
Expand All @@ -222,8 +203,6 @@ export async function POST(req: NextRequest) {
}
}, 2_000);

// All further writes/closes go through SafeStream to prevent
// enqueue-after-close exceptions from crashing the function.
const safe = new SafeStream(ctrl, encoder, keepAlive);

try {
Expand All @@ -232,8 +211,6 @@ export async function POST(req: NextRequest) {
githubToken,
isLocal,
localFiles,
// Don't register a cache checker for local uploads — there's nothing
// to cache against (no stable commitSha).
checkCache: isLocal
? undefined
: async (commitSha) => {
Expand All @@ -248,7 +225,6 @@ export async function POST(req: NextRequest) {
.maybeSingle();

if (cacheError) {
// Supabase returns soft errors — log and treat as miss
console.warn("[analyze] Cache query error:", cacheError.message);
return null;
}
Expand All @@ -257,10 +233,7 @@ export async function POST(req: NextRequest) {
},
});

// Persist fresh analyses only — cached results are already in the DB.
if (!isLocal && !result.cached) {
// DB insert is non-fatal. A failure here must NOT prevent the
// freshly-computed result from reaching the client.
try {
const { error: insertError } = await supabase.from("analyses").insert({
repo_url: repoUrl,
Expand All @@ -277,9 +250,6 @@ export async function POST(req: NextRequest) {
console.error("[analyze] DB insert threw:", err);
}

// RAG indexing is also non-fatal — a failure should never block
// the response. `fileContents` is always an array from the pipeline,
// but guard anyway for safety.
try {
await processAndStoreCodebase(
supabase,
Expand All @@ -300,8 +270,6 @@ export async function POST(req: NextRequest) {
if (err instanceof GitHubAuthError || message === "REQUIRE_GITHUB_AUTH") {
safe.sendError("REQUIRE_GITHUB_AUTH", { message: "GitHub auth required." });
} else if (err instanceof PipelineError) {
// Typed pipeline errors include a machine-readable code so the
// client can render specific UI (e.g., "Too many files" banner).
safe.sendError(message, { code: err.code });
} else {
safe.sendError(message);
Expand Down
26 changes: 18 additions & 8 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Messages missing" }, { status: 400 });
}


const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
Expand All @@ -32,17 +31,29 @@ export async function POST(req: NextRequest) {

const { data: { user } } = await supabase.auth.getUser();
if (user) {
const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email);
if (!isUnderLimit) {
return NextResponse.json({ error: "Daily limit reached." }, { status: 429 });
// Check pro status — pro users skip the usage limit entirely
const { data: profile } = await supabase
.from("profiles")
.select("plan_tier")
.eq("id", user.id)
.single();

const isPro = profile?.plan_tier === "pro";

if (!isPro) {
const isUnderLimit = await checkUsageLimit(supabase, user.id, user.email);
if (!isUnderLimit) {
return NextResponse.json(
{ error: "RATE_LIMIT_REACHED", message: "Daily limit reached." },
{ status: 429 }
);
}
}
}


let contextText = "No repository context provided.";
if (repoContext) {

contextText = JSON.stringify(repoContext).substring(0, 20000);
contextText = JSON.stringify(repoContext).substring(0, 20000);
}

const systemPrompt = `You are a Senior Systems Architect analyzing a codebase.
Expand All @@ -51,7 +62,6 @@ Use the provided JSON context about the repository to answer the user's question
REPOSITORY CONTEXT (JSON):
${contextText}`;


const res = await fetch(GROQ_URL, {
method: "POST",
headers: {
Expand Down
Loading
Loading