diff --git a/README.md b/README.md index e215bc4..0fba44a 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,8 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Code council -## Getting Started +An AI-powered code review platform that uses RAG to analyze code diffs with repository aware context, post structured reviews on the PRs. -First, run the development server: -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +![Dashboard](/public/res/dashboard.png) -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +![Review](/public/res/review.png) \ No newline at end of file diff --git a/app/api/webhooks/github/route.ts b/app/api/webhooks/github/route.ts index 62e2aaf..8448bb9 100644 --- a/app/api/webhooks/github/route.ts +++ b/app/api/webhooks/github/route.ts @@ -12,7 +12,7 @@ export async function POST(req: NextRequest) { if (event == "pull_request") { const action = body.action; const repo = body.repository.full_name; - const prNumber = body.Number; + const prNumber = body.pull_request.number; const [owner, repoName] = repo.split("/"); reviewPullRequest(owner, repoName, prNumber) diff --git a/app/dashboard/reviews/page.tsx b/app/dashboard/reviews/page.tsx new file mode 100644 index 0000000..fc30704 --- /dev/null +++ b/app/dashboard/reviews/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; +import Link from "next/link"; +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getReviews } from "@/modules/review/actions"; +import { ExternalLink, Search, Filter, GitPullRequest, Calendar, CheckCircle2, Clock, AlertCircle } from "lucide-react"; + +type Review = { + id: string; + prNumber: number; + prTitle: string; + prUrl: string; + review: string; + status: string; + createdAt: Date; + repository: { + name: string; + fullName: string; + owner: string; + }; +}; + +export default function ReviewsPage() { + const { data: reviews, isLoading } = useQuery({ + queryKey: ["reviews"], + queryFn: getReviews + }); + + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + + // Filter and search reviews + const filteredReviews = useMemo(() => { + if (!reviews) return []; + + return reviews.filter((review) => { + const matchesSearch = + review.prTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + review.repository.name.toLowerCase().includes(searchQuery.toLowerCase()) || + review.prNumber.toString().includes(searchQuery); + + const matchesStatus = statusFilter === "all" || review.status === statusFilter; + + return matchesSearch && matchesStatus; + }); + }, [reviews, searchQuery, statusFilter]); + + const getStatusIcon = (status: string) => { + switch (status.toLowerCase()) { + case "completed": + return ; + case "pending": + return ; + case "failed": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "completed": + return "bg-primary/10 text-primary border-primary/20"; + case "pending": + return "bg-accent/10 text-accent-foreground border-accent/20"; + case "failed": + return "bg-destructive/10 text-destructive border-destructive/20"; + default: + return "bg-muted text-muted-foreground border-border"; + } + }; + + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); + }; + + return ( +
+
+ {/* Header Section */} +
+
+

+ Code Reviews +

+

+ AI-powered reviews for your pull requests +

+
+ + {/* Search and Filter Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-card border-border focus:border-primary transition-colors" + /> +
+ +
+ +
+ + + +
+
+
+ + {/* Stats Bar */} +
+
+
+
+ +
+
+

{reviews?.length || 0}

+

Total Reviews

+
+
+
+ +
+
+
+ +
+
+

+ {reviews?.filter(r => r.status === "completed").length || 0} +

+

Completed

+
+
+
+ +
+
+
+ +
+
+

+ {reviews?.filter(r => r.status === "pending").length || 0} +

+

Pending

+
+
+
+
+
+ + {/* Reviews Grid */} + {isLoading ? ( +
+
+
+

Loading reviews...

+
+
+ ) : filteredReviews.length === 0 ? ( +
+
+
+ +
+
+

No reviews found

+

+ {searchQuery || statusFilter !== "all" + ? "Try adjusting your search or filters" + : "Reviews will appear here once PRs are analyzed"} +

+
+
+
+ ) : ( +
+ {filteredReviews.map((review) => ( + + +
+
+
+ + #{review.prNumber} + + + {getStatusIcon(review.status)} + {review.status} + +
+ + {review.prTitle} + + + + {review.repository.fullName} + + +
+ + + + +
+
+ + +
+

+
+ AI Review Summary +

+
+ {review.review.substring(0, 300)} + {review.review.length > 300 && "..."} +
+
+
+ + +
+
+ + {formatDate(review.createdAt)} +
+ +
+
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/subscription/page.tsx b/app/dashboard/subscription/page.tsx new file mode 100644 index 0000000..c3295f2 --- /dev/null +++ b/app/dashboard/subscription/page.tsx @@ -0,0 +1,468 @@ +"use client"; +import { customer, checkout } from "@/lib/auth-client"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { getSubscriptionData, syncSubscriptionStatus } from "@/modules/payment/actions"; +import { useQuery } from "@tanstack/react-query"; +import { + SpinnerGap, + CheckCircle, + XCircle, + Crown, + Rocket, + GitBranch, + GitPullRequest, + ArrowRight, + ArrowsClockwise, + Gear, + Infinity, + Check +} from "@phosphor-icons/react"; +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +const PLAN_FEATURES = { + free: [ + { + name: "Up to 7 repositories", + included: true + }, + { + name: "Up to 7 reviews per repository", + included: true + }, + { + name: "Basic code review", + included: true + }, + { + name: "Deep code analysis", + included: true + }, + { + name: "Community support", + included: true + } + ], + pro: [ + { + name: "Unlimited repositories", + included: true + }, + { + name: "Unlimited reviews", + included: true + }, + { + name: "Advanced code review", + included: true + }, + { + name: "Email notifications", + included: true + }, + { + name: "Priority support", + included: true + } + ], +} + + +export default function SubscriptionPage() { + const [checkoutLoading, setCheckoutLoading] = useState(false); + const [portalLoading, setPortalLoading] = useState(false); + const [syncLoading, setSyncLoading] = useState(false); + const searchParams = useSearchParams(); + const success = searchParams.get("success"); + const [showSuccessAlert, setShowSuccessAlert] = useState(false); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["subscription-data"], + queryFn: getSubscriptionData, + refetchOnWindowFocus: true, + }); + + useEffect(() => { + if (success === "true") { + setShowSuccessAlert(true); + toast.success("Subscription updated successfully!"); + // Remove the success param from URL + window.history.replaceState({}, '', '/dashboard/subscription'); + // Refetch data to get updated subscription + refetch(); + } + }, [success, refetch]); + + const handleSync = async () => { + setSyncLoading(true); + try { + const result = await syncSubscriptionStatus(); + if (result.success) { + toast.success("Subscription synced successfully!"); + refetch(); + } else { + toast.error(result.message || "Failed to sync subscription"); + } + } catch (error) { + toast.error("Failed to sync subscription"); + } finally { + setSyncLoading(false); + } + }; + + const handleUpgrade = async () => { + setCheckoutLoading(true); + try { + // The checkout function should use the slug defined in auth.ts + // which maps to the productId: "500d00ba-f6bf-43b9-8cb3-b492aa9b5842" + const result = await checkout({ + productId: "500d00ba-f6bf-43b9-8cb3-b492aa9b5842", + slug: "Pro" + }); + + if (result?.url) { + window.location.href = result.url; + } else { + console.error("Checkout result:", result); + toast.error("Failed to create checkout session - no URL returned"); + } + } catch (error: any) { + console.error("Checkout error:", error); + toast.error(error?.message || "Failed to start checkout"); + } finally { + setCheckoutLoading(false); + } + }; + + const handleManageSubscription = async () => { + setPortalLoading(true); + try { + const result = await customer.portal(); + + if (result.url) { + window.location.href = result.url; + } else { + toast.error("Failed to open customer portal"); + } + } catch (error) { + console.error("Portal error:", error); + toast.error("Failed to open customer portal"); + } finally { + setPortalLoading(false); + } + }; + + if (isLoading) { + return ( +
+
+ +

Loading subscription data...

+
+
+ ) + } + + if (!data?.user) { + return ( +
+
+ +

Authentication Required

+

Please sign in to view subscription data

+
+
+ ) + } + + const currentTier = data.user.subscriptionTier as "FREE" | "PRO"; + const isPro = currentTier === "PRO"; + const isActive = data.user.subscriptionStatus === "ACTIVE"; + const limits = data.limits; + + return ( +
+ {/* Header */} +
+
+

+ Subscription Plans +

+

+ Manage your subscription and billing +

+
+ + +
+ + {/* Success Alert */} + {showSuccessAlert && ( + + + + Your subscription has been updated successfully! + + + )} + + {/* Current Plan Badge */} +
+ + +
+
+ {isPro ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {currentTier} Plan + {isPro && isActive && ( + + Active + + )} +

+

+ {isPro + ? "Unlimited access to all features" + : "Limited access with upgrade available"} +

+
+
+
+
+
+
+ + {/* Usage Statistics */} + {limits && ( +
+

Current Usage

+
+ {/* Repositories Usage */} + + + + + Repositories + + + +
+
+ {limits.repositories.current} + + / {limits.repositories.limit || ( + + )} + +
+
+
+
+

+ {limits.repositories.canAdd + ? "You can add more repositories" + : "Repository limit reached"} +

+
+ + + + {/* Reviews Usage */} + + + + + Reviews Per Repository + + + +
+
+ + {isPro ? ( + + ) : "7"} + + + {isPro ? "unlimited" : "per repo"} + +
+

+ {isPro + ? "Unlimited reviews for all repositories" + : "Up to 7 reviews per repository"} +

+
+
+
+
+
+ )} + + {/* Plan Cards */} +
+

Available Plans

+
+ {/* Free Plan */} + + {!isPro && ( +
+ + Current Plan + +
+ )} + +
+
+ +
+ Free +
+ + $0 + /month + +
+ +
    + {PLAN_FEATURES.free.map((feature, idx) => ( +
  • + + {feature.name} +
  • + ))} +
+
+ + + +
+ + {/* Pro Plan */} + +
+ {isPro && ( +
+ + Current Plan + +
+ )} + +
+
+ +
+ Pro +
+ + $10 + /month + +
+ +
    + {PLAN_FEATURES.pro.map((feature, idx) => ( +
  • + + {feature.name} +
  • + ))} +
+
+ + {isPro ? ( + + ) : ( + + )} + + +
+
+ + {/* Additional Info */} + + +
+ +
+

Need help?

+

+ Contact our support team if you have any questions about your subscription or billing. +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/app-sidebar.tsx b/components/ui/app-sidebar.tsx index 97cbfe6..2e64abc 100644 --- a/components/ui/app-sidebar.tsx +++ b/components/ui/app-sidebar.tsx @@ -54,7 +54,7 @@ export const AppSidebar = () => { { href: "/dashboard", label: "Dashboard", icon: GithubLogoIcon }, { href: "/dashboard/repository", label: "Repositories", icon: BookIcon }, { href: "/dashboard/reviews", label: "Reviews", icon: TreeViewIcon }, - { href: "/dashboard/subscriptions", label: "Subscriptions", icon: CoinsIcon }, + { href: "/dashboard/subscription", label: "Subscriptions", icon: CoinsIcon }, { href: "/dashboard/settings", label: "Settings", icon: GearSixIcon }, ] diff --git a/components/ui/text-rotatot.tsx b/components/ui/text-rotatot.tsx new file mode 100644 index 0000000..306fe8d --- /dev/null +++ b/components/ui/text-rotatot.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { motion } from 'framer-motion' + +type TextRotatorProps = { + words: string[] + className?: string + typingDelayMs?: number + deletingDelayMs?: number + holdAtFullMs?: number + holdAtEmptyMs?: number + showCursor?: boolean +} + +type Phase = 'typing' | 'deleting' | 'pause-full' | 'pause-empty' + +export function TextRotator({ + words, + className, + typingDelayMs = 120, + deletingDelayMs = 60, + holdAtFullMs = 1200, + holdAtEmptyMs = 400, + showCursor = true, +}: TextRotatorProps) { + const safeWords = useMemo(() => (words && words.length > 0 ? words.filter(Boolean) : ['']), [words]) + const [wordIndex, setWordIndex] = useState(0) + const [subIndex, setSubIndex] = useState(0) + const [phase, setPhase] = useState('typing') + const safeIndex = safeWords.length > 0 ? Math.min(wordIndex, safeWords.length - 1) : 0 + + // Reset wordIndex when words array shrinks and current index is out of bounds + useEffect(() => { + if (wordIndex >= safeWords.length && safeWords.length > 0) { + setWordIndex(0) + setSubIndex(0) + setPhase('typing') + } + }, [safeWords.length, wordIndex]) + + useEffect(() => { + const currentWord = safeWords[safeIndex] ?? '' + + let timeoutId: ReturnType | undefined + if (phase === 'typing') { + if (subIndex < currentWord.length) { + timeoutId = setTimeout(() => setSubIndex((s) => s + 1), typingDelayMs) + } else { + timeoutId = setTimeout(() => setPhase('pause-full'), holdAtFullMs) + } + } else if (phase === 'deleting') { + if (subIndex > 0) { + timeoutId = setTimeout(() => setSubIndex((s) => s - 1), deletingDelayMs) + } else { + timeoutId = setTimeout(() => setPhase('pause-empty'), holdAtEmptyMs) + } + } else if (phase === 'pause-full') { + timeoutId = setTimeout(() => setPhase('deleting'), deletingDelayMs) + } else if (phase === 'pause-empty') { + timeoutId = setTimeout(() => { + setWordIndex((i) => (i + 1) % Math.max(1, safeWords.length)) + setPhase('typing') + }, typingDelayMs) + } + + return () => { + if (timeoutId) clearTimeout(timeoutId) + } + }, [safeWords, safeIndex, wordIndex, subIndex, phase, typingDelayMs, deletingDelayMs, holdAtFullMs, holdAtEmptyMs]) + + const text = (safeWords[safeIndex] ?? '').slice(0, subIndex) + + return ( + + {text} + {showCursor && ( + + ) +} + diff --git a/lib/auth-client.ts b/lib/auth-client.ts index cbf1375..c7309fb 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,5 +1,7 @@ import { createAuthClient } from "better-auth/react"; +import { polarClient } from "@polar-sh/better-auth"; export const { signIn, signUp, useSession, signOut } = createAuthClient({ baseURL: process.env.BETTER_AUTH_URL, + plugins: [polarClient()], }); \ No newline at end of file diff --git a/lib/auth.ts b/lib/auth.ts index 14e2ce7..828103f 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,16 +1,90 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import prisma from "./db"; +import { polar, checkout, portal, usage, webhooks } from "@polar-sh/better-auth"; +import { Polar } from "@polar-sh/sdk"; +import { polarClient } from "@/modules/payment/config/polar"; +import { updatePolarCustomerId, updateUserTier } from "@/modules/payment/lib/subscription"; export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", // or "mysq..etc }), socialProviders: { - github:{ - clientId:process.env.GITHUB_CLIENT_ID as string, - clientSecret:process.env.GITHUB_CLIENT_SECRET as string, - scope:["repo"] + github: { + clientId: process.env.GITHUB_CLIENT_ID as string, + clientSecret: process.env.GITHUB_CLIENT_SECRET as string, + scope: ["repo"] } - } + }, + plugins: [ + polar({ + client: polarClient, + createCustomerOnSignUp: true, + use: [ + checkout({ + products: [ + { + productId: "500d00ba-f6bf-43b9-8cb3-b492aa9b5842", + slug: "Code-council" + } + ], + successUrl: process.env.POLAR_SUCCESS_URL, + authenticatedUsersOnly: true + }), + portal({ + returnUrl: process.env.POLAR_RETURN_URL || "http://localhost:3002/dashbaord", + }), + usage(), + webhooks({ + secret: process.env.POLAR_WEBHOOK_SERCRET!, + onSubscriptionActive: async (payload) => { + const customerid = payload.data.customerId; + const user = await prisma.user.findUnique({ + where: { + polarCustomerID: customerid + } + }); + if (user) { + await updateUserTier(user.id, "PRO", "ACTIVE", payload.data.id) + } + }, + onSubscriptionCanceled: async (payload) => { + const customerid = payload.data.customerId; + const user = await prisma.user.findUnique({ + where: { + polarCustomerID: customerid + } + }); + if (user) { + await updateUserTier(user.id, user.subscriptionTier as any, "CANCELED") + } + }, + onSubscriptionRevoked: async (payload) => { + const customerid = payload.data.customerId; + const user = await prisma.user.findUnique({ + where: { + polarCustomerID: customerid + } + }); + if (user) { + await updateUserTier(user.id, "FREE", "EXPIRED") + } + }, + onOrderPaid: async () => { }, // later + onCustomerCreated: async (payload) => { + const user = await prisma.user.findUnique({ + where: { + email: payload.data.email + } + }); + if (user) { + await updatePolarCustomerId(user.id, payload.data.id) + } + }, + + }) + ], + }) + ] }); \ No newline at end of file diff --git a/modules/ai/action/index.ts b/modules/ai/action/index.ts index 1aa87c2..35ea2c8 100644 --- a/modules/ai/action/index.ts +++ b/modules/ai/action/index.ts @@ -4,6 +4,7 @@ import prisma from "@/lib/db"; import { retrieveContext } from "../lib/rag"; import { getPullRequestDiff } from "@/modules/github/lib/github"; import { inngest } from "@/inngest/client"; +import { canCreateReview, incrementReviewCount } from "@/modules/payment/lib/subscription"; export async function reviewPullRequest(owner: string, repo: string, prNumber: number) { try { @@ -29,6 +30,10 @@ export async function reviewPullRequest(owner: string, repo: string, prNumber: n throw new Error("Repository not found") } + const canReview = await canCreateReview(repository.user.id, repository.id); + if (!canReview) { + throw new Error("Peasant, You've reached the maximum number of reviews allowed in FREE tier") + } const githubAccount = repository.user.accounts[0]; if (!githubAccount?.accessToken) { throw new Error("Github account token not found for owner") @@ -43,6 +48,8 @@ export async function reviewPullRequest(owner: string, repo: string, prNumber: n userId: repository.user.id } }) + + await incrementReviewCount(repository.user.id, repository.id); return { success: true, message: "Review Queued" } } catch (error) { diff --git a/modules/ai/lib/rag.ts b/modules/ai/lib/rag.ts index 44135af..dda0b61 100644 --- a/modules/ai/lib/rag.ts +++ b/modules/ai/lib/rag.ts @@ -1,10 +1,11 @@ import { pineconeIndex } from "@/lib/pincecone"; import { embed } from "ai"; import { google } from "@ai-sdk/google"; +import "dotenv/config"; export async function generateEmbedding(text: string) { const { embedding } = await embed({ - model: google.embedding("text-embedding-04"), + model: google.embedding("gemini-embedding-001"), // 3072 dimensions, new state of art model as of 14th Jan, 2026 value: text }) return embedding diff --git a/modules/auth/components/login-ui.tsx b/modules/auth/components/login-ui.tsx index 0c343c2..afcfe5e 100644 --- a/modules/auth/components/login-ui.tsx +++ b/modules/auth/components/login-ui.tsx @@ -2,9 +2,9 @@ import React from "react" import { useState } from "react" import { signIn } from "@/lib/auth-client" -import { GithubLogoIcon, Atom, ShieldCheck } from '@phosphor-icons/react' +import { GithubLogoIcon, ShieldCheck } from '@phosphor-icons/react' import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" +import { TextRotator } from "@/components/ui/text-rotatot" const LoginUI = () => { @@ -25,8 +25,11 @@ const LoginUI = () => { setLoading(false) } } + + const rotatorWords = ["before you merge", "written by AI", "before CI fails"] + return ( -
+
{/* Background Effects */}
@@ -34,30 +37,34 @@ const LoginUI = () => {
-
-
- - {/* Header */} -
-
- -
-
-

- Code Council -

-

- AI-powered code reviews for engineering teams. -

-
+
+ {/* Left: Branding */} +
+
+

+ Ship Production-Ready Code +

+

+ +

+

+ AI-powered code reviews for engineering teams. +

+
- {/* Auth Container */} + {/* Right: Auth */} +