diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index 1349a02f..4139c410 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -14,6 +14,7 @@ class Settings(BaseSettings): # Platforms github_token: str = "" + github_webhook_secret: str = "" discord_bot_token: str = "" # DB configuration @@ -39,6 +40,14 @@ class Settings(BaseSettings): # Backend URL backend_url: str = "" + # CORS configuration + allow_origins: list[str] = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ] + # Onboarding UX toggles onboarding_show_oauth_button: bool = True diff --git a/backend/main.py b/backend/main.py index b7ad80a6..8df9e156 100644 --- a/backend/main.py +++ b/backend/main.py @@ -107,17 +107,23 @@ async def lifespan(app: FastAPI): # Configure CORS api.add_middleware( CORSMiddleware, - allow_origins=[ - "http://localhost:5173", # Vite default dev server - "http://localhost:3000", # Alternative dev server - "http://127.0.0.1:5173", - "http://127.0.0.1:3000", - ], + allow_origins=settings.allow_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) + +@api.exception_handler(Exception) +async def global_exception_handler(request, exc): + """Log all unhandled exceptions and return a JSON error response.""" + logger.error(f"Unhandled exception: {str(exc)}", exc_info=True) + return Response( + content='{"detail": "Internal Server Error"}', + status_code=500, + media_type="application/json" + ) + @api.get("/favicon.ico") async def favicon(): """Return empty favicon to prevent 404 logs""" diff --git a/backend/routes.py b/backend/routes.py index 7dbd6463..f38cd137 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -1,12 +1,15 @@ -import asyncio -import uuid +import hmac +import hashlib import logging -from fastapi import APIRouter, Request, HTTPException +import uuid +import json +from fastapi import APIRouter, Request, HTTPException, Header from app.core.events.event_bus import EventBus from app.core.events.enums import EventType, PlatformType from app.core.events.base import BaseEvent from app.core.handler.handler_registry import HandlerRegistry from pydantic import BaseModel +from app.core.config.settings import settings router = APIRouter() @@ -35,50 +38,74 @@ def register_event_handlers(): event_bus.register_handler(EventType.PR_COMMENTED, sample_handler, PlatformType.GITHUB) event_bus.register_handler(EventType.PR_MERGED, sample_handler, PlatformType.GITHUB) +async def verify_signature(body: bytes, x_hub_signature_256: str): + """Verify that the payload was sent from GitHub by validating the SHA256 signature.""" + if not settings.github_webhook_secret: + # If secret is not configured, we skip verification (not recommended for production) + return + + if not x_hub_signature_256: + raise HTTPException(status_code=401, detail="X-Hub-Signature-256 header is missing") + + signature = hmac.new( + settings.github_webhook_secret.encode(), + body, + hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(f"sha256={signature}", x_hub_signature_256): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + @router.post("/github/webhook") -async def github_webhook(request: Request): - payload = await request.json() - event_header = request.headers.get("X-GitHub-Event") - logging.info(f"Received GitHub event: {event_header}") +async def github_webhook( + request: Request, + x_github_event: str = Header(None), + x_hub_signature_256: str = Header(None) +): + # Read body once to avoid stream exhaustion + body = await request.body() + + # Verify GitHub signature + await verify_signature(body, x_hub_signature_256) + + try: + payload = json.loads(body.decode()) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON payload") from None + logging.info(f"Received GitHub event: {x_github_event}") event_type = None - # Handle issue events - if event_header == "issues": - action = payload.get("action") - if action == "opened": - event_type = EventType.ISSUE_CREATED - elif action == "closed": - event_type = EventType.ISSUE_CLOSED - elif action == "edited": - event_type = EventType.ISSUE_UPDATED - - # Handle issue comment events - elif event_header == "issue_comment": - action = payload.get("action") - if action == "created": - event_type = EventType.ISSUE_COMMENTED + # Mapping GitHub events and actions to EventType + event_mapping = { + "issues": { + "opened": EventType.ISSUE_CREATED, + "closed": EventType.ISSUE_CLOSED, + "edited": EventType.ISSUE_UPDATED, + }, + "issue_comment": { + "created": EventType.ISSUE_COMMENTED, + }, + "pull_request": { + "opened": EventType.PR_CREATED, + "edited": EventType.PR_UPDATED, + }, + "pull_request_review_comment": { + "created": EventType.PR_COMMENTED, + }, + } - # Handle pull request events - elif event_header == "pull_request": + if x_github_event in event_mapping: action = payload.get("action") - if action == "opened": - event_type = EventType.PR_CREATED - elif action == "edited": - event_type = EventType.PR_UPDATED - elif action == "closed": - # Determine if the PR was merged or simply closed + event_type = event_mapping[x_github_event].get(action) + + # Special casing for PR merged + if x_github_event == "pull_request" and action == "closed": if payload.get("pull_request", {}).get("merged"): event_type = EventType.PR_MERGED else: logging.info("Pull request closed without merge; no event dispatched.") - # Handle pull request comment events - elif event_header in ["pull_request_review_comment", "pull_request_comment"]: - action = payload.get("action") - if action == "created": - event_type = EventType.PR_COMMENTED - # Dispatch the event if we have a matching type if event_type: event = BaseEvent( @@ -90,6 +117,6 @@ async def github_webhook(request: Request): ) await event_bus.dispatch(event) else: - logging.info(f"No matching event type for header: {event_header} with action: {payload.get('action')}") + logging.info(f"No matching event type for header: {x_github_event} with action: {payload.get('action')}") return {"status": "ok"} diff --git a/frontend/Devr logo.png b/frontend/Devr logo.png new file mode 100644 index 00000000..f54690d8 Binary files /dev/null and b/frontend/Devr logo.png differ diff --git a/frontend/index.html b/frontend/index.html index 87106d51..6521557f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,16 @@ - - - - - Devr.AI - - -
- - - + + + + + + Devr.AI + + + +
+ + + + \ No newline at end of file diff --git a/DevrAI.svg b/frontend/public/DevrAI.svg similarity index 100% rename from DevrAI.svg rename to frontend/public/DevrAI.svg diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f70aa6c..08889c7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import { useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { AnimatePresence } from 'framer-motion'; -import toast, { Toaster } from 'react-hot-toast'; +import { Toaster } from 'react-hot-toast'; import Sidebar from './components/layout/Sidebar'; import Dashboard from './components/dashboard/Dashboard'; @@ -15,112 +15,50 @@ import LandingPage from './components/landing/LandingPage'; import LoginPage from './components/pages/LoginPage'; import ProfilePage from './components/pages/ProfilePage'; import SignUpPage from './components/pages/SignUpPage'; -import { supabase } from './lib/supabaseClient'; import ForgotPasswordPage from './components/pages/ForgotPasswordPage'; import ResetPasswordPage from './components/pages/ResetPasswordPage'; +import { useAuth } from './hooks/useAuth'; +import { RepositoryData } from './types'; -// Define proper TypeScript interfaces -interface RepositoryData { - id: string; - name: string; - full_name: string; - description?: string; - html_url: string; - stargazers_count: number; - forks_count: number; - language?: string; - created_at: string; - updated_at: string; - owner: { - login: string; - avatar_url: string; - }; +interface ProtectedLayoutProps { + isSidebarOpen: boolean; + setIsSidebarOpen: (isOpen: boolean) => void; + handleLogout: () => Promise; } +const ProtectedLayout = ({ isSidebarOpen, setIsSidebarOpen, handleLogout }: ProtectedLayoutProps) => ( +
+ +
+
+ + + +
+
+
+); + function App() { const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [repoData, setRepoData] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(false); - - // Auto login if user has already logged in - useEffect(() => { - supabase.auth.getSession().then(({ data, error }) => { - if (error) { - toast.error('User Login Failed'); - console.error('Error checking session:', error); - return; - } - setIsAuthenticated(!!data.session); - }); - - const { data: subscription } = supabase.auth.onAuthStateChange( - (event, session) => { - switch (event) { - case "SIGNED_IN": - setIsAuthenticated(true); - toast.success("Signed in!"); - break; - - case "SIGNED_OUT": - setIsAuthenticated(false); - setRepoData(null); - toast.success("Signed out!"); - break; - - case "PASSWORD_RECOVERY": - toast("Check your email to reset your password."); - break; - case "TOKEN_REFRESHED": - // Session refreshed silently - break; - case "USER_UPDATED": - // User profile updated - break; - } - } - ); + const { isAuthenticated, loading, logout } = useAuth(); - return () => { - subscription.subscription.unsubscribe(); - }; - }, []); - - const handleLogin = () => { - setIsAuthenticated(true); - }; + if (loading) { + return
Loading...
; + } const handleLogout = async () => { - const { error } = await supabase.auth.signOut(); - if (error) { - toast.error('Logout failed'); - console.error('Error during logout:', error); - return; - } - toast.success('Signed out!'); - setIsAuthenticated(false); - setRepoData(null); + await logout(); }; - const ProtectedLayout = () => ( -
- -
-
- - - -
-
-
- ); return ( @@ -133,7 +71,7 @@ function App() { isAuthenticated ? ( ) : ( - + ) } /> @@ -157,7 +95,15 @@ function App() { : + isAuthenticated ? ( + + ) : ( + + ) } > } /> diff --git a/frontend/src/components/contributors/ContributorsPage.tsx b/frontend/src/components/contributors/ContributorsPage.tsx index d0ee0e2f..08d43a2e 100644 --- a/frontend/src/components/contributors/ContributorsPage.tsx +++ b/frontend/src/components/contributors/ContributorsPage.tsx @@ -1,22 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-hot-toast'; import ContributorCard from './ContributorCard'; +import { RepositoryData } from '../../types'; interface Props { - repoData: any; // Fetched repository stats + repoData: RepositoryData | null; } const ContributorsPage: React.FC = ({ repoData }) => { - const [contributors, setContributors] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (repoData?.contributors) { - setContributors(repoData.contributors); - } - }, [repoData]); + const contributors = repoData?.contributors || []; const handleExportReport = () => { toast.success('Generating contributor report...'); @@ -30,14 +23,6 @@ const ContributorsPage: React.FC = ({ repoData }) => { return
No data available. Please analyze a repository first.
; } - if (loading) { - return
Loading...
; - } - - if (error) { - return
{error}
; - } - return ( = ({ repoData }) => {

Manage and track contributor activity

- = ({ repoData }) => { > Export Report - = ({ repoData }) => { {/* Contributors Grid */}
{contributors.map((contributor) => ( - = ({ repoData }) => { if (!repoData) { - return
No data available. Please analyze a repository first.
; + return ( +
+ +

No data available. Please analyze a repository first.

+
+ ); } const handleNewIntegration = () => { @@ -26,7 +32,7 @@ const Dashboard: React.FC = ({ repoData }) => { >

Dashboard Overview

- = ({ repoData }) => { {/* Stats Section */}
- } title="Active Contributors" value={repoData.contributors?.length || 0} trend={12} /> - } title="Open PRs" value={repoData.pull_requests?.open || 0} - trend={repoData.pull_requests?.open || 0} + trend={8} // Real growth percentage placeholder /> - } title="Community Posts" - value="892" // Placeholder, replace with dynamic if available + value="892" // TODO: Make dynamic trend={8} /> - } title="Response Rate" - value="94%" // Placeholder, replace with dynamic if available + value="94%" // TODO: Make dynamic trend={5} />
@@ -67,24 +73,21 @@ const Dashboard: React.FC = ({ repoData }) => { {/* Quick Actions */}

Quick Actions

- } - connected={true} description="Automate your GitHub workflow" features={['Issue triage', 'PR reviews', 'Welcome messages']} /> - } - connected={false} description="Engage with your community" features={['Support channels', 'Role management', 'Event notifications']} /> - } - connected={true} description="Team collaboration hub" features={['Channel monitoring', 'Instant notifications', 'Command center']} /> diff --git a/frontend/src/components/dashboard/StatCard.tsx b/frontend/src/components/dashboard/StatCard.tsx index d44a2fca..6e9fef84 100644 --- a/frontend/src/components/dashboard/StatCard.tsx +++ b/frontend/src/components/dashboard/StatCard.tsx @@ -4,25 +4,25 @@ import { motion } from 'framer-motion'; interface StatCardProps { icon: React.ReactNode; title: string; - value: string; + value: string | number; trend: number; } export default function StatCard({ icon, title, value, trend }: StatCardProps) { return ( -
- {icon} - 0 ? 'text-green-400' : 'text-yellow-500'}`} @@ -31,7 +31,7 @@ export default function StatCard({ icon, title, value, trend }: StatCardProps) {

{title}

- = ({ repoData }) => { - if (!repoData || !repoData.pull_requests) { - return
No data available. Please analyze a repository first.
; - } const [selectedRange, setSelectedRange] = React.useState('Last Week'); const [activeIndex, setActiveIndex] = React.useState(0); + if (!repoData || !repoData.pull_requests) { + return
No data available. Please analyze a repository first.
; + } + const onPieEnter = (_: any, index: number) => { setActiveIndex(index); }; @@ -167,11 +163,10 @@ const AnalyticsPage: React.FC = ({ repoData }) => { {card.value} {card.trend} @@ -335,7 +330,7 @@ const AnalyticsPage: React.FC = ({ repoData }) => { dataKey="value" onMouseEnter={onPieEnter} > - {pieData.map((entry, index) => ( + {pieData.map((_, index) => ( { const { cx, cy, - innerRadius, - outerRadius, - startAngle, - endAngle, - fill, payload, percent, value, @@ -376,7 +366,6 @@ const renderActiveShape = (props: any) => { {`(${value})`} - ); }; diff --git a/frontend/src/components/pages/LoginPage.tsx b/frontend/src/components/pages/LoginPage.tsx index 5a91c2d3..2e77e4fd 100644 --- a/frontend/src/components/pages/LoginPage.tsx +++ b/frontend/src/components/pages/LoginPage.tsx @@ -18,9 +18,6 @@ interface InputFieldProps extends React.InputHTMLAttributes { icon: React.ElementType; } -interface LoginPageProps { - onLogin: () => void; -} const AuthLayout = ({ children }: AuthLayoutProps) => (
@@ -47,7 +44,7 @@ const InputField = ({ icon: Icon, ...props }: InputFieldProps) => (
); -export default function LoginPage({ onLogin }: LoginPageProps) { +export default function LoginPage() { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); @@ -57,19 +54,17 @@ export default function LoginPage({ onLogin }: LoginPageProps) { const email = formData.get('email') as string; const password = formData.get('password') as string; setIsLoading(true); - const {data,error} = await supabase.auth.signInWithPassword({ + const { data, error } = await supabase.auth.signInWithPassword({ email: email, password: password, }); setIsLoading(false); - if(data && !error){ + if (data && !error) { toast.success('Successfully logged in!'); - onLogin(); navigate('/'); } - else - { - toast.error(error?.message ||"An Unknown error occured!"); + else { + toast.error(error?.message || "An Unknown error occurred!"); } }; diff --git a/frontend/src/components/pages/PullRequestsPage.tsx b/frontend/src/components/pages/PullRequestsPage.tsx index 1859f501..f9173068 100644 --- a/frontend/src/components/pages/PullRequestsPage.tsx +++ b/frontend/src/components/pages/PullRequestsPage.tsx @@ -1,30 +1,21 @@ import React from 'react'; import { motion } from 'framer-motion'; - -interface PullRequest { - number: number; - title: string; - author: { - login: string; - avatar_url: string; - profile_url: string; - }; - state: string; - url: string; - comments?: number; - created_at?: string; -} +import { RepositoryData } from '../../types'; interface Props { - repoData: { pull_requests: { details: PullRequest[] } } | null; + repoData: RepositoryData | null; } const PullRequestsPage: React.FC = ({ repoData }) => { if (!repoData || !repoData.pull_requests) { - return
No data available. Please analyze a repository first.
; + return ( +
+

No data available. Please analyze a repository first.

+
+ ); } - const prs = repoData.pull_requests.details; + const prs = repoData.pull_requests.details || []; const getStatusColor = (state: string) => { switch (state.toLowerCase()) { @@ -51,41 +42,51 @@ const PullRequestsPage: React.FC = ({ repoData }) => {

Pull Requests

-
- - - - - - - - - - - - {prs.map((pr) => ( - - - - - - + {prs.length === 0 ? ( +
+ No pull requests found for this repository. +
+ ) : ( +
+
TitleAuthorStatusPR NumberLink
{pr.title} - {pr.author.login} - - {pr.author.login} - - - {pr.state} - #{pr.number} - - View PR - -
+ + + + + + + - ))} - -
TitleAuthorStatusPR NumberLink
-
+ + + {prs.map((pr) => ( + + {pr.title} + + + + + + {pr.state} + + + #{pr.number} + + + View PR + + + + ))} + + +
+ )} ); }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..250e0250 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '../lib/supabaseClient'; +import toast from 'react-hot-toast'; + +export const useAuth = () => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + supabase.auth.getSession().then(({ data, error }) => { + if (error) { + console.error('Error checking session:', error); + } + setIsAuthenticated(!!data?.session); + setLoading(false); + }); + + const { data: subscription } = supabase.auth.onAuthStateChange( + (event, _session) => { + switch (event) { + case "SIGNED_IN": + setIsAuthenticated(true); + toast.success("Signed in!"); + break; + case "SIGNED_OUT": + setIsAuthenticated(false); + toast.success("Signed out!"); + break; + default: + break; + } + } + ); + + return () => { + subscription.subscription.unsubscribe(); + }; + }, []); + + const logout = async () => { + const { error } = await supabase.auth.signOut(); + if (error) { + toast.error('Logout failed'); + return; + } + setIsAuthenticated(false); + }; + + return { isAuthenticated, loading, logout }; +}; diff --git a/frontend/src/lib/supabaseClient.ts b/frontend/src/lib/supabaseClient.ts index 7e8b7a25..279e32b7 100644 --- a/frontend/src/lib/supabaseClient.ts +++ b/frontend/src/lib/supabaseClient.ts @@ -5,7 +5,7 @@ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ""; const supabaseAnonKey = import.meta.env.VITE_SUPABASE_KEY || ""; if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing Supabase configuration. Please set VITE_SUPABASE_URL and VITE_SUPABASE_KEY environment variables.'); + console.warn('Missing Supabase configuration. Please set VITE_SUPABASE_URL and VITE_SUPABASE_KEY environment variables.'); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 00000000..bcbadcd4 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,42 @@ +export interface PullRequest { + number: number; + title: string; + author: { + login: string; + avatar_url: string; + profile_url: string; + }; + state: string; + url: string; + comments?: number; + created_at?: string; +} + +export interface Contributor { + login: string; + avatar_url: string; + contributions: number; +} + +export interface RepositoryData { + id: string; + name: string; + full_name: string; + description?: string; + html_url: string; + stargazers_count: number; + forks_count: number; + language?: string; + created_at: string; + updated_at: string; + owner: { + login: string; + avatar_url: string; + }; + contributors?: Contributor[]; + pull_requests?: { + open: number; + total: number; + details?: PullRequest[]; + }; +}