diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index 93930bf..5a6adf0 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -42,8 +42,10 @@ async def github_callback_handler(request: Request, code: str = None, state: str """ # Verify state parameter to prevent CSRF attacks stored_state = request.session.get('oauth_state') + print(f"DEBUG: Received state: {state}, Stored state: {stored_state}") if not state or not stored_state or state != stored_state: request.session.pop('oauth_state', None) # Clean up state + print(f"DEBUG: State mismatch - received: {state}, stored: {stored_state}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid state parameter. CSRF check failed." diff --git a/backend/app/api/v1/endpoints/match.py b/backend/app/api/v1/endpoints/match.py index 0f49ec4..090ac36 100644 --- a/backend/app/api/v1/endpoints/match.py +++ b/backend/app/api/v1/endpoints/match.py @@ -112,7 +112,7 @@ async def match_issues( all_keywords.extend(topics) # Get top matched issues - result = get_top_matched_issues( + result = await get_top_matched_issues( query_text=text_blob, keywords=all_keywords, languages=languages, diff --git a/backend/app/main.py b/backend/app/main.py index f1608ae..85b003d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.sessions import SessionMiddleware @@ -15,11 +16,11 @@ app.add_middleware( SessionMiddleware, secret_key=settings.SECRET_KEY, - # --- Optional Session Cookie Parameters (consider for production) --- - # session_cookie="your_app_session", # Customize the cookie name - # max_age=7 * 24 * 60 * 60, # Example: Cookie expires after 7 days - # same_site="lax", # Or "strict" for more security, but test carefully - # https_only=True, # Recommended: Send cookie only over HTTPS + # --- Session Cookie Parameters --- + session_cookie="issuematch_session", # Customize the cookie name + max_age=7 * 24 * 60 * 60, # Example: Cookie expires after 7 days + same_site="lax", # Use lax for localhost development to avoid CSRF issues + https_only=False, # No HTTPS in development ) @@ -32,7 +33,6 @@ ] # In production, allow requests from any origin -import os if os.environ.get("RENDER", False): origins = ["*"] # Allow all origins in production @@ -62,14 +62,16 @@ async def read_root(): # --- Optional: Startup/Shutdown Event Handlers --- # These functions can run code when the server starts or stops. # Useful for loading resources (like a FAISS index) or cleaning up. -# @app.on_event("startup") -# async def startup_event(): -# """ -# Code to run when the application starts up. -# Example: Load ML models, FAISS index, connect to databases. -# """ -# print("Backend server starting up...") -# # load_faiss_index() # Example placeholder +from .services.faiss_search import load_model + +@app.on_event("startup") +async def startup_event(): + """ + Code to run when the application starts up. + Example: Load ML models, FAISS index, connect to databases. + """ + print("Backend server starting up...") + load_model() # @app.on_event("shutdown") # async def shutdown_event(): diff --git a/backend/app/services/faiss_search.py b/backend/app/services/faiss_search.py index afab5f3..9b96273 100644 --- a/backend/app/services/faiss_search.py +++ b/backend/app/services/faiss_search.py @@ -1,4 +1,5 @@ -import requests +import asyncio +import aiohttp from sentence_transformers import SentenceTransformer import faiss, re import numpy as np @@ -17,53 +18,48 @@ # Global variables model = None -# Initialize the model -try: - logger.info(f"Loading sentence transformer model: {MODEL_NAME}") - model = SentenceTransformer(MODEL_NAME) - logger.info("Model loaded successfully") -except Exception as e: - logger.error(f"Error loading model: {str(e)}") - model = None - - -def fetch_github_issues(keywords: List[str], top_k: int = TOP_PER_KEYWORD, github_token: Optional[str] = None) -> List[ - Dict[str, Any]]: - """ - Fetch GitHub issues based on keywords. - - Args: - keywords: List of keywords to search for - top_k: Number of issues to fetch per keyword - github_token: GitHub API token for authentication +def load_model(): + global model + if model is None: + try: + logger.info(f"Loading sentence transformer model: {MODEL_NAME}") + model = SentenceTransformer(MODEL_NAME) + logger.info("Model loaded successfully") + except Exception as e: + logger.error(f"Error loading model: {str(e)}") + model = None + +load_model() + +async def fetch_issues_for_keyword(session: aiohttp.ClientSession, keyword: str, top_k: int, headers: Dict[str, str]) -> List[Dict[str, Any]]: + query = f'{keyword}+state:open+type:issue' + url = f"https://api.github.com/search/issues?q={query}&per_page={top_k}" + logger.info(f"Fetching issues for keyword: {keyword}") + async with session.get(url, headers=headers) as response: + if response.status == 200: + json_response = await response.json() + items = json_response.get('items', []) + logger.info(f"Found {len(items)} issues for keyword: {keyword}") + return items[:top_k] + else: + logger.error(f"Error for keyword: {keyword}, Status Code: {response.status}") + if response.status == 403: + logger.error("Rate limit exceeded or authentication required") + elif response.status == 401: + logger.error("Unauthorized - check your GitHub token") + return [] - Returns: - List of GitHub issues - """ +async def fetch_github_issues(keywords: List[str], top_k: int = TOP_PER_KEYWORD, github_token: Optional[str] = None) -> List[Dict[str, Any]]: logger.info(f"Fetching GitHub issues for keywords: {keywords}") - headers = {"Accept": "application/vnd.github+json"} if github_token: headers["Authorization"] = f"Bearer {github_token}" - all_issues = [] - for keyword in keywords: - query = f'label:"{keyword}"+state:open+type:issue' - url = f"https://api.github.com/search/issues?q={query}&per_page={top_k}" - - # logger.info(f"Fetching issues for keyword: {keyword}") - response = requests.get(url, headers=headers) - - if response.status_code == 200: - items = response.json().get('items', []) - # logger.info(f"Found {len(items)} issues for keyword: {keyword}") - all_issues.extend(items[:top_k]) # Take top N only - else: - logger.error(f"Error for keyword: {keyword}, Status Code: {response.status_code}") - if response.status_code == 403: - logger.error("Rate limit exceeded or authentication required") - elif response.status_code == 401: - logger.error("Unauthorized - check your GitHub token") + async with aiohttp.ClientSession() as session: + tasks = [fetch_issues_for_keyword(session, keyword, top_k, headers) for keyword in keywords] + results = await asyncio.gather(*tasks) + + all_issues = [issue for sublist in results for issue in sublist] # Deduplicate by URL unique_issues = list({issue['html_url']: issue for issue in all_issues}.values()) @@ -176,7 +172,7 @@ def format_issues_json(issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: return results -def get_top_matched_issues( +async def get_top_matched_issues( query_text: str, keywords: List[str], languages: List[str] = None, @@ -199,7 +195,7 @@ def get_top_matched_issues( global model try: - # logger.info(f"Getting top matched issues for query: {query_text[:100]}...") + logger.info(f"Getting top matched issues for query: {query_text[:100]}...") # Check if model is loaded if model is None: @@ -221,16 +217,22 @@ def get_top_matched_issues( search_keywords = list(set(search_keywords)) logger.info(f"Search keywords: {search_keywords}") - # Fetch issues - issues = fetch_github_issues(search_keywords, top_k=TOP_PER_KEYWORD, github_token=github_token) + # Fetch issues asynchronously + issues = await fetch_github_issues(search_keywords, top_k=TOP_PER_KEYWORD, github_token=github_token) + # Fallback: If no issues found with specific keywords, try with general programming keywords if not issues: - logger.warning("No issues fetched") + logger.warning("No issues fetched with specific keywords, trying fallback keywords") + fallback_keywords = ["python", "javascript", "java", "react", "node", "good first issue"] + issues = await fetch_github_issues(fallback_keywords, top_k=TOP_PER_KEYWORD, github_token=github_token) + + if not issues: + logger.warning("No issues fetched even with fallback keywords") return { "recommendations": [], "issues_fetched": 0, "issues_indexed": 0, - "message": "No issues found for the given keywords" + "message": "No issues found for the given keywords. Try adjusting your search criteria or check back later." } # Prepare issue texts for embedding @@ -264,4 +266,4 @@ def get_top_matched_issues( "issues_fetched": 0, "issues_indexed": 0, "message": f"Error matching issues: {str(e)}" - } \ No newline at end of file + } diff --git a/backend/requirements.txt b/backend/requirements.txt index f650cf9..9d5e374 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ beautifulsoup4==4.13.0 requests numpy vertexai +aiohttp==3.9.1 diff --git a/frontend/app/match/page.tsx b/frontend/app/match/page.tsx index e5dc225..724f12c 100644 --- a/frontend/app/match/page.tsx +++ b/frontend/app/match/page.tsx @@ -1,3 +1,6 @@ +"use client" + +import { useState } from "react" import { Slider } from "@/components/slider" import { IssuesList } from "@/components/issues-list" import { SortDropdown } from "@/components/sort-dropdown" @@ -5,8 +8,13 @@ import { SortOptions } from "@/components/sort-options" import { InspirationSidebar } from "@/components/inspiration-sidebar" import { NavigationButtons } from "@/components/navigation-buttons" import { Footer } from "@/components/footer" +import { IssueFilter } from "@/components/issue-filter" export default function Home() { + const [filters, setFilters] = useState<{ keywords: string[] }>({ + keywords: [], + }) + return (
{/* Hero Slider Section */} @@ -28,11 +36,16 @@ export default function Home() { {/**/}
-
-

Recommended

- +
+
+

Recommended

+
+ + +
+
- +
diff --git a/frontend/app/skills/page.tsx b/frontend/app/skills/page.tsx index b889250..07b4487 100644 --- a/frontend/app/skills/page.tsx +++ b/frontend/app/skills/page.tsx @@ -136,7 +136,7 @@ export default function SkillsPage() { // Loading state - show while checking auth or loading user data if (loading || authLoading) { return ( -
+

Loading...

@@ -146,7 +146,7 @@ export default function SkillsPage() { } return ( -
+
{currentScreen === "welcome" && } {currentScreen === "test" && } diff --git a/frontend/components/issue-filter.tsx b/frontend/components/issue-filter.tsx new file mode 100644 index 0000000..fed71e0 --- /dev/null +++ b/frontend/components/issue-filter.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState, useEffect } from "react" +import { useAuth } from "@/context/auth-context" +import { getUserByGithubId } from "@/lib/firebase-utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Filter, ChevronDown } from "lucide-react" + +interface IssueFilterProps { + onFilterChange: (filters: { keywords: string[] }) => void +} + +const PREDEFINED_SKILLS = [ + "JavaScript", "TypeScript", "Python", "Java", "C++", "C#", "Go", "Rust", + "React", "Vue", "Angular", "Node.js", "Express", "Django", "Flask", "Spring", + "Machine Learning", "Data Science", "AI", "Web Development", "Mobile Development", + "DevOps", "Cloud Computing", "Database", "API", "Testing" +] + +export function IssueFilter({ onFilterChange }: IssueFilterProps) { + const [selectedKeywords, setSelectedKeywords] = useState([]) + const [userSkills, setUserSkills] = useState([]) + const [isOpen, setIsOpen] = useState(false) + const { user } = useAuth() + + // Fetch user skills + useEffect(() => { + const fetchUserSkills = async () => { + if (!user) return + try { + const result = await getUserByGithubId(user.id) + if (result.success && result.data?.skill_keywords) { + setUserSkills(result.data.skill_keywords) + } + } catch (error) { + console.error("Error fetching user skills:", error) + } + } + fetchUserSkills() + }, [user]) + + // Combine user skills with predefined skills + const allSkills = Array.from(new Set([...userSkills, ...PREDEFINED_SKILLS])) + + const handleSkillToggle = (skill: string) => { + const updated = selectedKeywords.includes(skill) + ? selectedKeywords.filter(s => s !== skill) + : [...selectedKeywords, skill] + setSelectedKeywords(updated) + } + + const applyFilters = () => { + onFilterChange({ + keywords: selectedKeywords + }) + setIsOpen(false) + } + + const clearFilters = () => { + setSelectedKeywords([]) + onFilterChange({ keywords: [] }) + } + + const totalSelected = selectedKeywords.length + + return ( + + + + + +
+
+

Keywords

+
+ {allSkills.map(skill => ( +
+ handleSkillToggle(skill)} + /> + +
+ ))} +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/components/issues-list.tsx b/frontend/components/issues-list.tsx index f783005..1d57e75 100644 --- a/frontend/components/issues-list.tsx +++ b/frontend/components/issues-list.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useRef, useCallback } from "react" import { IssueCard } from "@/components/issue-card" +import { useAuth } from "@/context/auth-context" +import { getUserByGithubId } from "@/lib/firebase-utils" const MATCH_COLORS = ["#8b5cf6", "#ec4899", "#f97316", "#10b981", "#3b82f6"] @@ -21,14 +23,47 @@ interface IssuesListProps { type: "recommended" | "trending" | "favorite" | "recent" } -export function IssuesList({ type }: IssuesListProps) { +import { IssueFilter } from "@/components/issue-filter" + +export function IssuesList({ type, filters = { keywords: [] } }: IssuesListProps & { filters?: { keywords: string[] } }) { const [issues, setIssues] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [userSkills, setUserSkills] = useState([]) const timeoutRef = useRef(null) + const { user, isLoading: authLoading } = useAuth() + + // Fetch user skills from Firebase + const fetchUserSkills = useCallback(async () => { + if (!user) { + setUserSkills([]) + return + } + try { + const result = await getUserByGithubId(user.id) + if (result.success && result.data?.skill_keywords) { + setUserSkills(result.data.skill_keywords) + } else { + setUserSkills([]) + } + } catch (error) { + console.error("Error fetching user skills:", error) + setUserSkills([]) + } + }, [user]) // Create a fetchIssues function that can be called both in useEffect and by the retry button const fetchIssues = useCallback(async () => { + if (authLoading) { + return + } + + if (!user) { + setError("Please log in to view issues") + setLoading(false) + return + } + // Clear any existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current) @@ -38,7 +73,7 @@ export function IssuesList({ type }: IssuesListProps) { timeoutRef.current = setTimeout(() => { setLoading(false) setError("Request timed out. Please try again later.") - }, 25000) // 15 seconds timeout + }, 25000) // 25 seconds timeout setLoading(true) setError(null) @@ -46,32 +81,45 @@ export function IssuesList({ type }: IssuesListProps) { try { console.log(`Fetching ${type} issues...`) - // Different endpoints based on issue type + // Build endpoint with filters for recommended type let endpoint = "" - switch (type) { - case "recommended": - endpoint = "http://localhost:8000/api/v1/match/match-issue?keywords=machine-learning&keywords=java&max_results=5" - break - case "trending": - endpoint = "http://localhost:8000/api/v1/match/trending-issues" - break - case "favorite": - endpoint = "http://localhost:8000/api/v1/match/favorite-issues" - break - case "recent": - endpoint = "http://localhost:8000/api/v1/match/recent-issues" - break - default: - endpoint = "http://localhost:8000/api/v1/match/match-issue?keywords=machine-learning&keywords=java&max_results=5" + if (type === "recommended") { + const params = new URLSearchParams() + if (filters.keywords.length > 0) { + filters.keywords.forEach((skill) => { + params.append("keywords", skill) + }) + } else { + // fallback keywords if no user skills + params.append("keywords", "machine-learning") + params.append("keywords", "java") + } + + params.append("max_results", "5") + endpoint = `http://localhost:8000/api/v1/match/match-issue?${params.toString()}` + } else { + switch (type) { + case "trending": + endpoint = "http://localhost:8000/api/v1/match/trending-issues" + break + case "favorite": + endpoint = "http://localhost:8000/api/v1/match/favorite-issues" + break + case "recent": + endpoint = "http://localhost:8000/api/v1/match/recent-issues" + break + default: + endpoint = "http://localhost:8000/api/v1/match/match-issue?keywords=machine-learning&keywords=java&max_results=5" + } } const response = await fetch(endpoint, { - method: 'GET', + method: "GET", headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }, - credentials: 'include' + credentials: "include", }) // Clear the timeout since we got a response @@ -82,7 +130,7 @@ export function IssuesList({ type }: IssuesListProps) { if (!response.ok) { const errorText = await response.text() - throw new Error(`API error: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`) + throw new Error(`API error: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`) } const data = await response.json() @@ -104,9 +152,14 @@ export function IssuesList({ type }: IssuesListProps) { setLoading(false) setError(err instanceof Error ? err.message : "Failed to fetch issues") } - }, [type]) + }, [type, filters, authLoading, user]) - // Effect to fetch issues when the component mounts or type changes + // Effect to fetch user skills when user changes + useEffect(() => { + fetchUserSkills() + }, [fetchUserSkills]) + + // Effect to fetch issues when the component mounts or type, filters change useEffect(() => { fetchIssues() @@ -116,7 +169,7 @@ export function IssuesList({ type }: IssuesListProps) { clearTimeout(timeoutRef.current) } } - }, [fetchIssues]) // fetchIssues already depends on type + }, [fetchIssues]) // fetchIssues already depends on type, filters, authLoading // Transform API issues to the format expected by IssueCard const transformedIssues = issues.map((issue, index) => ({ @@ -127,44 +180,55 @@ export function IssuesList({ type }: IssuesListProps) { skillMatch: Math.min(Math.round(issue.similarity_score * 100 + 30), 99), // Cap at 99% skills: issue.labels?.slice(0, 5) || ["No labels"], matchColor: MATCH_COLORS[index % MATCH_COLORS.length], - issueUrl: issue.issue_url + issueUrl: issue.issue_url, })) return ( -
- {loading ? ( -
-
-
-
-
-
-
-
+ <> +
+ {loading ? ( +
+
+
+
+
+
+
+
+
-
- ) : error ? ( -
-
Error loading issues
-
{error}
- -
- ) : transformedIssues.length === 0 ? ( -
-
No issues found
-
Try adjusting your search criteria or check back later
-
- ) : ( - transformedIssues.map((issue) => ( - - )) - )} -
+ ) : error ? ( +
+
Error loading issues
+
{error}
+ {error === "Please log in to view issues" ? ( + + Log In + + ) : ( + + )} +
+ ) : transformedIssues.length === 0 ? ( +
+
No issues found
+
Try adjusting your search criteria or check back later
+
+ ) : ( + transformedIssues.map((issue) => ( + + )) + )} +
+ ) -} \ No newline at end of file +} diff --git a/frontend/components/skills/skills-test.tsx b/frontend/components/skills/skills-test.tsx index e8556a8..56d1f91 100644 --- a/frontend/components/skills/skills-test.tsx +++ b/frontend/components/skills/skills-test.tsx @@ -164,16 +164,16 @@ export default function SkillsTest({ onComplete }: SkillsTestProps) { const canProceed = selectedOptions[currentQuestionIndex] !== -1 return ( -
+
-
+
Question {currentQuestionIndex + 1} of {questions.length} {Math.round(progress)}% Complete
- -
+ +
@@ -186,7 +186,7 @@ export default function SkillsTest({ onComplete }: SkillsTestProps) { transition={{ duration: 0.3 }} className={`${isTransitioning ? "opacity-0" : "opacity-100"} transition-opacity duration-300`} > -

{currentQuestion.question}

+

{currentQuestion.question}

{currentQuestion.options.map((option, index) => ( @@ -194,22 +194,24 @@ export default function SkillsTest({ onComplete }: SkillsTestProps) { key={index} className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${ selectedOptions[currentQuestionIndex] === index - ? "border-purple-500 bg-purple-900/30" - : "border-gray-700 hover:border-purple-400 bg-gray-800/30" + ? "border-blue-500 dark:border-purple-500 bg-blue-50/50 dark:bg-purple-900/30" + : "border-gray-300 dark:border-gray-700 hover:border-blue-400 dark:hover:border-purple-400 bg-white/50 dark:bg-gray-800/30" }`} onClick={() => handleOptionSelect(index)} >
{selectedOptions[currentQuestionIndex] === index && (
)}
- {option.text} + {option.text}
))} @@ -220,7 +222,7 @@ export default function SkillsTest({ onComplete }: SkillsTestProps) { variant="outline" onClick={handleBack} disabled={isFirstQuestion} - className={`${isFirstQuestion ? "opacity-0" : "opacity-100"} border-gray-600 text-white hover:bg-purple-900/30 hover:text-purple-300`} + className={`${isFirstQuestion ? "opacity-0" : "opacity-100"} border-gray-300 dark:border-gray-600 text-gray-700 dark:text-white hover:bg-blue-50 dark:hover:bg-purple-900/30 hover:text-blue-700 dark:hover:text-purple-300`} > Back @@ -230,7 +232,7 @@ export default function SkillsTest({ onComplete }: SkillsTestProps) {