diff --git a/src/App.jsx b/src/App.jsx index fb172d8..59cac14 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import ContributorProfilePage from './pages/ContributorProfilePage' import NetworkPage from './pages/NetworkPage' import AnalyticsPage from './pages/AnalyticsPage' import GovernancePage from './pages/GovernancePage' +import IssuesPage from './pages/IssuesPage' import SettingsPage from './pages/SettingsPage' import Footer from './components/layout/Footer' import Support from './pages/Support' @@ -50,6 +51,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 3b60426..a66ab0a 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { FiBarChart2, FiBook, FiChevronRight, FiExternalLink, FiHeart, FiHome, FiMenu, FiSettings, FiShare2, FiShield, FiSun, FiUsers, FiZap, FiX } from 'react-icons/fi' +import { FiAlertCircle, FiBarChart2, FiBook, FiChevronRight, FiExternalLink, FiHeart, FiHome, FiMenu, FiSettings, FiShare2, FiShield, FiSun, FiUsers, FiZap, FiX } from 'react-icons/fi' import { useApp } from '../context/AppContext' import ThemeToggle from './ThemeToggle' import Logo from "../assets/og-logo.svg?react"; @@ -9,6 +9,7 @@ const LINKS = [ { to: '/overview', label: 'Overview', icon: FiHome }, { to: '/repositories', label: 'Repositories', icon: FiBook }, { to: '/contributors', label: 'Contributors', icon: FiUsers }, + { to: '/issues', label: 'Issues', icon: FiAlertCircle }, { to: '/network', label: 'Network', icon: FiShare2 }, { to: '/analytics', label: 'Analytics', icon: FiBarChart2 }, { to: '/governance', label: 'Governance', icon: FiShield }, diff --git a/src/pages/IssuesPage.jsx b/src/pages/IssuesPage.jsx new file mode 100644 index 0000000..83ef196 --- /dev/null +++ b/src/pages/IssuesPage.jsx @@ -0,0 +1,518 @@ +import React, { useState, useMemo, useEffect, useRef } from 'react' +import { + FiAlertCircle, + FiCheckCircle, + FiMessageSquare, + FiUser, + FiFolder, + FiSearch, + FiExternalLink, + FiClock, + FiDatabase, + FiTag, + FiFilter +} from 'react-icons/fi' +import { useApp } from '../context/AppContext' +import { C, PageTitle } from '../components/UI' +import EmptyStateCard from '../components/EmptyStateCard' +import AnalysisBanner from '../components/AnalysisBanner' +import { useNavigate } from 'react-router-dom' + +export default function IssuesPage() { + const { model, issuesData, loading, govLoading, runAudit } = useApp() + const navigate = useNavigate() + const auditAttemptedRef = useRef(false) + + useEffect(() => { + if (model && (!issuesData || Object.keys(issuesData).length === 0) && !govLoading && !auditAttemptedRef.current) { + auditAttemptedRef.current = true + runAudit() + } + }, [model, issuesData, govLoading, runAudit]) + + const [search, setSearch] = useState('') + const [orgFilter, setOrgFilter] = useState('All Organizations') + const [repoFilter, setRepoFilter] = useState('All Repositories') + const [stateFilter, setStateFilter] = useState('open') // 'open', 'closed', 'all' + const [groupBy, setGroupBy] = useState('none') // 'none', 'repo', 'author' + const [page, setPage] = useState(1) + const pageSize = 20 + + // Aggregate all issues from issuesData + const allIssues = useMemo(() => { + if (!issuesData || Object.keys(issuesData).length === 0) return [] + const list = [] + Object.entries(issuesData).forEach(([repoKey, issues]) => { + if (!Array.isArray(issues)) return + const [orgLogin, repoName] = repoKey.split('/') + issues.forEach(item => { + // Exclude pull requests (GitHub API includes PRs in issues endpoint) + if (item.pull_request) return + list.push({ + ...item, + orgLogin: orgLogin || item.repository_url?.split('/')?.slice(-2, -1)[0] || '', + repoName: repoName || item.repository_url?.split('/')?.slice(-1)[0] || '', + repoKey, + }) + }) + }) + return list + }, [issuesData]) + + // Extract orgs list for filter dropdown + const orgList = useMemo(() => { + const orgs = new Set(allIssues.map(i => i.orgLogin).filter(Boolean)) + return ['All Organizations', ...Array.from(orgs)] + }, [allIssues]) + + // Extract repos list for filter dropdown + const repoList = useMemo(() => { + const repos = new Set( + allIssues + .filter(i => orgFilter === 'All Organizations' || i.orgLogin === orgFilter) + .map(i => i.repoName) + .filter(Boolean) + ) + return ['All Repositories', ...Array.from(repos)] + }, [allIssues, orgFilter]) + + // Filter issues + const filteredIssues = useMemo(() => { + return allIssues.filter(issue => { + // State filter + if (stateFilter !== 'all' && issue.state !== stateFilter) return false + + // Org filter + if (orgFilter !== 'All Organizations' && issue.orgLogin !== orgFilter) return false + + // Repo filter + if (repoFilter !== 'All Repositories' && issue.repoName !== repoFilter) return false + + // Search filter + if (search.trim()) { + const q = search.toLowerCase().trim() + const titleMatch = issue.title?.toLowerCase().includes(q) + const numberMatch = String(issue.number).includes(q) + const authorMatch = issue.user?.login?.toLowerCase().includes(q) + const assigneeMatch = issue.assignee?.login?.toLowerCase().includes(q) + const labelMatch = issue.labels?.some(l => l.name?.toLowerCase().includes(q)) + if (!titleMatch && !numberMatch && !authorMatch && !assigneeMatch && !labelMatch) { + return false + } + } + return true + }) + }, [allIssues, stateFilter, orgFilter, repoFilter, search]) + + // Summary Metrics + const metrics = useMemo(() => { + const openCount = allIssues.filter(i => i.state === 'open').length + const closedCount = allIssues.filter(i => i.state === 'closed').length + const affectedRepos = new Set(allIssues.map(i => i.repoKey)).size + const authors = new Set(allIssues.map(i => i.user?.login).filter(Boolean)).size + return { openCount, closedCount, affectedRepos, authors } + }, [allIssues]) + + // Reset page when filters change + useMemo(() => { + setPage(1) + }, [search, orgFilter, repoFilter, stateFilter, groupBy]) + + // Grouping logic + const groupedIssues = useMemo(() => { + if (groupBy === 'repo') { + const map = {} + filteredIssues.forEach(issue => { + const key = `${issue.orgLogin}/${issue.repoName}` + if (!map[key]) map[key] = [] + map[key].push(issue) + }) + return map + } else if (groupBy === 'author') { + const map = {} + filteredIssues.forEach(issue => { + const key = issue.user?.login || 'Unknown' + if (!map[key]) map[key] = [] + map[key].push(issue) + }) + return map + } + return null + }, [filteredIssues, groupBy]) + + // Pagination for flat list view + const totalPages = Math.ceil(filteredIssues.length / pageSize) || 1 + const paginatedIssues = useMemo(() => { + if (groupBy !== 'none') return filteredIssues + const start = (page - 1) * pageSize + return filteredIssues.slice(start, start + pageSize) + }, [filteredIssues, page, pageSize, groupBy]) + + if (loading || govLoading) { + return ( +
+ + +
+ Fetching issue data across repositories... +
+
+ ) + } + + if (!model || allIssues.length === 0) { + return ( +
+ + +
+ } + title="No Issues Data Available" + description="Run an analysis from the home page to inspect issues across your repositories." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+
+ ) + } + + return ( +
+ + + + + {/* Summary Metrics */} +
+
+
+ Open Issues +
+
{metrics.openCount.toLocaleString()}
+
+ +
+
+ Closed Issues +
+
{metrics.closedCount.toLocaleString()}
+
+ +
+
+ Repositories +
+
{metrics.affectedRepos.toLocaleString()}
+
+ +
+
+ Issue Creators +
+
{metrics.authors.toLocaleString()}
+
+
+ + {/* Filter & Controls Bar */} +
+
+ {/* Search Box */} +
+ + setSearch(e.target.value)} + style={{ ...C.input, width: '100%', paddingLeft: 34 }} + /> +
+ + {/* Org Filter */} + {orgList.length > 2 && ( + + )} + + {/* Repo Filter */} + + + {/* State Filter Tabs */} +
+ {['open', 'closed', 'all'].map(s => ( + + ))} +
+ + {/* Group By Selector */} +
+ Group by: + +
+
+
+ + {/* Results Header */} +
+
+ Showing {filteredIssues.length} {stateFilter} {filteredIssues.length === 1 ? 'issue' : 'issues'} +
+
+ + {/* Issue Items / Grouped Views */} + {filteredIssues.length === 0 ? ( +
+ No issues match your current filter criteria. +
+ ) : groupBy !== 'none' ? ( + // Grouped View (by Repo or Author) +
+ {Object.entries(groupedIssues || {}).map(([groupKey, issues]) => ( +
+
+ {groupBy === 'repo' ? : } + {groupKey} + + {issues.length} {issues.length === 1 ? 'issue' : 'issues'} + +
+
+ {issues.map(issue => ( + + ))} +
+
+ ))} +
+ ) : ( + // Flat List View with Pagination +
+
+ {paginatedIssues.map(issue => ( + + ))} +
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ )} +
+ ) +} + +// Sub-component for individual issue row +function IssueRow({ issue }) { + const isOpen = issue.state === 'open' + const createdDate = new Date(issue.created_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + + return ( +
+ {/* State Icon */} +
+ {isOpen ? ( + + ) : ( + + )} +
+ + {/* Main Content */} +
+ + + {/* Labels */} + {issue.labels && issue.labels.length > 0 && ( +
+ {issue.labels.map(l => { + const bgHex = l.color ? `#${l.color}` : 'var(--surface)' + return ( + + {l.name} + + ) + })} +
+ )} + + {/* Meta Line */} +
+ + {issue.orgLogin}/{issue.repoName} + + + + {createdDate} + + + {issue.user && ( + + {issue.user.avatar_url && ( + {issue.user.login} + )} + Author: {issue.user.login} + + )} + + {issue.assignee ? ( + + {issue.assignee.avatar_url && ( + {issue.assignee.login} + )} + Assignee: {issue.assignee.login} + + ) : ( + Unassigned + )} +
+
+ + {/* Comment Count */} + {issue.comments > 0 && ( +
+ + {issue.comments} +
+ )} +
+ ) +} diff --git a/src/pages/IssuesPage.test.jsx b/src/pages/IssuesPage.test.jsx new file mode 100644 index 0000000..32baff7 --- /dev/null +++ b/src/pages/IssuesPage.test.jsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import IssuesPage from './IssuesPage' + +vi.mock('../context/AppContext', () => ({ + useApp: () => ({ + model: { + totalRepos: [ + { orgLogin: 'AOSSIE', name: 'OrgExplorer' } + ] + }, + issuesData: { + 'AOSSIE/OrgExplorer': [ + { + id: 101, + number: 1, + title: 'First issue for testing', + state: 'open', + created_at: '2026-09-01T10:00:00Z', + html_url: 'https://github.com/AOSSIE/OrgExplorer/issues/1', + user: { login: 'tester1', avatar_url: '' }, + assignee: { login: 'maintainer1', avatar_url: '' }, + labels: [{ id: 1, name: 'bug', color: 'ff0000' }], + comments: 3 + }, + { + id: 102, + number: 2, + title: 'Pull request item that should be excluded', + state: 'open', + created_at: '2026-09-02T10:00:00Z', + html_url: 'https://github.com/AOSSIE/OrgExplorer/pull/2', + pull_request: { url: 'https://api.github.com/repos/AOSSIE/OrgExplorer/pulls/2' }, + user: { login: 'tester2' } + }, + { + id: 103, + number: 3, + title: 'Closed issue for testing', + state: 'closed', + created_at: '2026-09-03T10:00:00Z', + html_url: 'https://github.com/AOSSIE/OrgExplorer/issues/3', + user: { login: 'tester3', avatar_url: '' }, + labels: [{ id: 2, name: 'feature', color: '00ff00' }], + comments: 1 + } + ] + }, + loading: false + }) +})) + +describe('IssuesPage', () => { + it('renders issues page title and metrics correctly', () => { + render( + + + + ) + + expect(screen.getByText('Issues Explorer')).toBeInTheDocument() + expect(screen.getByText('First issue for testing')).toBeInTheDocument() + expect(screen.queryByText('Pull request item that should be excluded')).not.toBeInTheDocument() + }) + + it('filters issues by state correctly', () => { + render( + + + + ) + + // Open tab (default) + expect(screen.getByText('#1')).toBeInTheDocument() + expect(screen.getByText('First issue for testing')).toBeInTheDocument() + expect(screen.queryByText('Closed issue for testing')).not.toBeInTheDocument() + + // Switch to Closed tab + fireEvent.click(screen.getByRole('button', { name: /^closed$/i })) + expect(screen.queryByText('First issue for testing')).not.toBeInTheDocument() + expect(screen.getByText('Closed issue for testing')).toBeInTheDocument() + + // Switch to All tab + fireEvent.click(screen.getByRole('button', { name: /^all$/i })) + expect(screen.getByText('First issue for testing')).toBeInTheDocument() + expect(screen.getByText('Closed issue for testing')).toBeInTheDocument() + }) +})