diff --git a/src/App.jsx b/src/App.jsx index 94f8d27..a3732c6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import RepositoriesPage from './pages/RepositoriesPage' import ContributorsPage from './pages/ContributorsPage' import ContributorProfilePage from './pages/ContributorProfilePage' import NetworkPage from './pages/NetworkPage' +import TeamsPage from './pages/TeamsPage' import AnalyticsPage from './pages/AnalyticsPage' import GovernancePage from './pages/GovernancePage' import SettingsPage from './pages/SettingsPage' @@ -36,6 +37,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index ce4084f..db3e5a5 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -10,6 +10,7 @@ const LINKS = [ { to: '/overview', label: 'Overview' }, { to: '/repositories', label: 'Repositories' }, { to: '/contributors', label: 'Contributors' }, + { to: '/teams', label: 'Teams Explorer' }, { to: '/network', label: 'Network' }, { to: '/analytics', label: 'Analytics' }, { to: '/governance', label: 'Governance' }, diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx new file mode 100644 index 0000000..0ad3ed4 --- /dev/null +++ b/src/pages/TeamsPage.jsx @@ -0,0 +1,768 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import * as d3 from 'd3' +import { useNavigate } from 'react-router-dom' +import { useApp } from '../context/AppContext' +import { C, PageTitle, Spinner } from '../components/UI' +import { + FiUsers, FiDatabase, FiExternalLink, FiPlus, FiArrowLeft, + FiAlertCircle, FiLock, FiInfo, FiTrash2, FiUserPlus, FiAlertTriangle +} from 'react-icons/fi' +import { fetchOrgTeams, fetchTeamMembers, fetchTeamRepos, updateTeamMembership } from '../services/github' +import AnalysisBanner from '../components/AnalysisBanner' + +export default function TeamsPage() { + const navigate = useNavigate() + const { model, orgs, pat, isComplete, loading: appLoading, runFullExplore } = useApp() + + const [teams, setTeams] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + // Selection/Filters + const [searchQuery, setSearchQuery] = useState('') + const [selectedTeamSlug, setSelectedTeamSlug] = useState(null) + + // Graph rendering variables + const svgRef = useRef(null) + const simRef = useRef(null) + const dialogRef = useRef(null) + const [tooltip, setTooltip] = useState(null) + + // Drag-and-drop assign modal state + const [assignModal, setAssignModal] = useState(null) + const [assigning, setAssigning] = useState(false) + const [assignError, setAssignError] = useState('') + + // Resolve primary organization name + const orgName = useMemo(() => { + return orgs[0]?.login || '' + }, [orgs]) + + // Lazy load organization teams + useEffect(() => { + if (!orgName) return + if (!pat) { + setTeams([]) + setLoading(false) + setError('') + return + } + + let ignore = false + setLoading(true) + setError('') + + fetchOrgTeams(orgName, pat) + .then(async (fetchedTeams) => { + if (ignore) return + if (!fetchedTeams || !fetchedTeams.length) { + setTeams([]) + setLoading(false) + return + } + + // Fetch members and repos for each team in concurrency-limited batches of 5 + const enriched = [] + const batchSize = 5 + for (let i = 0; i < fetchedTeams.length; i += batchSize) { + if (ignore) return + const batch = fetchedTeams.slice(i, i + batchSize) + const results = await Promise.all( + batch.map(async (team) => { + let members = [] + let repos = [] + let partialError = null + + try { + members = await fetchTeamMembers(orgName, team.slug, pat) + } catch (e) { + partialError = e.message || 'Failed to load members' + } + + try { + repos = await fetchTeamRepos(orgName, team.slug, pat) + } catch (e) { + partialError = partialError || e.message || 'Failed to load repositories' + } + + return { ...team, members, repos, partialError } + }) + ) + enriched.push(...results) + } + + if (ignore) return + setTeams(enriched) + setLoading(false) + }) + .catch((err) => { + if (ignore) return + console.error('Failed to load org teams:', err) + if (err.message === 'RATE_LIMIT') { + setError('GitHub rate limit exceeded. Please check Settings.') + } else if (err.message === 'FORBIDDEN') { + setError('Access denied. Please ensure your Personal Access Token (PAT) has the "read:org" scope enabled.') + } else { + setError('Failed to load organization teams. Verify your Personal Access Token and settings.') + } + setLoading(false) + }) + + return () => { + ignore = true + } + }, [orgName, pat]) + + // Modal key listeners and keyboard focus trap + useEffect(() => { + if (!assignModal) return + + const activeEl = document.activeElement + + if (dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (focusables.length > 0) { + focusables[0].focus() + } else { + dialogRef.current.focus() + } + } + + function handleKeyDown(e) { + if (e.key === 'Escape') { + setAssignModal(null) + return + } + + if (e.key === 'Tab' && dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + if (focusables.length === 0) return + + const first = focusables[0] + const last = focusables[focusables.length - 1] + + if (e.shiftKey) { + if (document.activeElement === first) { + last.focus() + e.preventDefault() + } + } else { + if (document.activeElement === last) { + first.focus() + e.preventDefault() + } + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + if (activeEl && typeof activeEl.focus === 'function') { + activeEl.focus() + } + } + }, [assignModal]) + + // Filtered teams list based on search + const filteredTeams = useMemo(() => { + return teams.filter(t => + t.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (t.description && t.description.toLowerCase().includes(searchQuery.toLowerCase())) + ) + }, [teams, searchQuery]) + + // Generate D3 Force Graph Nodes and Links + useEffect(() => { + if (!svgRef.current || teams.length === 0) return + + const el = svgRef.current + const W = el.clientWidth || 800 + const H = 550 + const svg = d3.select(el) + svg.selectAll('*').remove() + svg.attr('viewBox', `0 0 ${W} ${H}`) + + // 1. Construct nodes & links mapping + const nodesMap = new Map() + const links = [] + + // Build teams + teams.forEach(team => { + // Ignore teams not matched by search query if a search is active + const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (team.description && team.description.toLowerCase().includes(searchQuery.toLowerCase())) + if (searchQuery && !matchesSearch) return + + const teamId = `team:${team.slug}` + nodesMap.set(teamId, { + id: teamId, + type: 'team', + label: team.name, + color: 'var(--purple)', + size: 24, + data: team + }) + + // Add members nodes and connections + team.members.forEach(member => { + const memberId = `member:${member.login}` + if (!nodesMap.has(memberId)) { + nodesMap.set(memberId, { + id: memberId, + type: 'member', + label: member.login, + avatar: member.avatar_url, + size: 14, + data: member + }) + } + links.push({ source: memberId, target: teamId }) + }) + + // Add repos nodes and connections + team.repos.forEach(repo => { + const repoId = `repo:${repo.name}` + if (!nodesMap.has(repoId)) { + // Look up in the analytical model totalRepos list to resolve computed scores + const modelRepo = model?.totalRepos?.find(r => r.name === repo.name) + const score = repo.healthScore ?? modelRepo?.healthScore ?? 65 + const healthColor = score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444' + + nodesMap.set(repoId, { + id: repoId, + type: 'repo', + label: repo.name, + color: healthColor, + size: 14, + data: { + ...repo, + healthScore: score, + forks_count: modelRepo?.forks_count ?? repo.forks_count ?? 0, + stargazers_count: modelRepo?.stargazers_count ?? repo.stargazers_count ?? 0 + } + }) + } + links.push({ source: repoId, target: teamId }) + }) + }) + + const nodes = Array.from(nodesMap.values()) + + const g = svg.append('g') + const zoom = d3.zoom().scaleExtent([0.15, 3]).on('zoom', (e) => g.attr('transform', e.transform)) + svg.call(zoom) + + // Draw link edges + const link = g.append('g') + .selectAll('line') + .data(links) + .join('line') + .attr('stroke', 'var(--border)') + .attr('stroke-opacity', 0.6) + .attr('stroke-width', 1.5) + + // Draw nodes g wrapper + const node = g.append('g') + .selectAll('g') + .data(nodes) + .join('g') + .attr('cursor', 'pointer') + .call( + d3.drag() + .on('start', (e, d) => { + if (!e.active) sim.alphaTarget(0.3).restart() + d.fx = d.x + d.fy = d.y + }) + .on('drag', (e, d) => { + d.fx = e.x + d.fy = e.y + }) + .on('end', (e, d) => { + if (!e.active) sim.alphaTarget(0) + d.fx = null + d.fy = null + + // Drag and drop assignment logic: dropped close to a team node + if (d.type === 'member') { + const threshold = 50 + let targetTeam = null + let minDistance = Infinity + + nodes.forEach(n => { + if (n.type === 'team') { + const dx = e.x - n.x + const dy = e.y - n.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < threshold && dist < minDistance) { + minDistance = dist + targetTeam = n.data + } + } + }) + + if (targetTeam) { + // Check if already in target team + const isMember = targetTeam.members.some(m => m.login === d.data.login) + if (!isMember) { + setAssignError('') + setAssignModal({ + username: d.data.login, + teamName: targetTeam.name, + teamSlug: targetTeam.slug, + avatar: d.data.avatar_url + }) + } + } + } + }) + ) + .on('mouseover', (event, d) => { + // Highlight links + link + .attr('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? 'var(--accent)' : 'var(--border)') + .attr('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.08) + + // Show Tooltip + const rect = el.getBoundingClientRect() + setTooltip({ + x: event.clientX - rect.left + 15, + y: event.clientY - rect.top - 15, + node: d + }) + }) + .on('mouseout', () => { + link.attr('stroke', 'var(--border)').attr('stroke-opacity', 0.6) + setTooltip(null) + }) + .on('click', (e, d) => { + if (d.type === 'team') { + setSelectedTeamSlug(d.data.slug) + } + }) + + // Draw customized layouts for nodes depending on type + node.each(function(d) { + const selection = d3.select(this) + + if (d.type === 'team') { + // Render Team Node as shield/polygons or distinct shapes + selection.append('polygon') + .attr('points', '-16,-20 16,-20 22,0 0,25 -22,0') + .attr('fill', d.color) + .attr('stroke', 'var(--bg)') + .attr('stroke-width', 2) + + selection.append('text') + .text('T') + .attr('text-anchor', 'middle') + .attr('dy', 5) + .attr('fill', '#fff') + .attr('font-weight', 'bold') + .attr('font-size', 12) + .attr('pointer-events', 'none') + + } else if (d.type === 'member') { + // Render Member Node as circular avatar + const r = d.size + const clipId = `avatar-clip-${d.id}` + + svg.append('defs') + .append('clipPath') + .attr('id', clipId) + .append('circle') + .attr('r', r) + .attr('cx', 0) + .attr('cy', 0) + + selection.append('image') + .attr('href', d.avatar) + .attr('x', -r) + .attr('y', -r) + .attr('width', r * 2) + .attr('height', r * 2) + .attr('clip-path', `url(#${clipId})`) + + selection.append('circle') + .attr('r', r) + .attr('fill', 'none') + .attr('stroke', 'var(--text2)') + .attr('stroke-width', 1.5) + + } else if (d.type === 'repo') { + // Render Repository Node as rectangular blocks + selection.append('rect') + .attr('x', -10) + .attr('y', -10) + .attr('width', 20) + .attr('height', 20) + .attr('rx', 3) + .attr('fill', d.color) + .attr('stroke', 'var(--bg)') + .attr('stroke-width', 1.5) + } + + // Add node titles + const labelY = d.type === 'team' ? 32 : 22 + selection.append('text') + .text(d.label.length > 14 ? d.label.slice(0, 12) + '..' : d.label) + .attr('text-anchor', 'middle') + .attr('dy', labelY) + .attr('font-size', 9) + .attr('fill', 'var(--text2)') + .attr('pointer-events', 'none') + }) + + // Setup force simulation + const sim = d3.forceSimulation(nodes) + .force('link', d3.forceLink(links).id(d => d.id).distance(80).strength(0.4)) + .force('charge', d3.forceManyBody().strength(-150)) + .force('center', d3.forceCenter(W / 2, H / 2)) + .force('collide', d3.forceCollide(d => d.type === 'team' ? 32 : 18)) + + simRef.current = sim + + sim.on('tick', () => { + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y) + node.attr('transform', d => `translate(${d.x},${d.y})`) + }) + + return () => { + if (simRef.current) simRef.current.stop() + } + }, [teams, searchQuery, model, appLoading]) + + // Trigger Team Membership Assignment + const handleAssignMembership = async () => { + if (!assignModal || assigning) return + + setAssigning(true) + setAssignError('') + + try { + await updateTeamMembership(orgName, assignModal.teamSlug, assignModal.username, pat) + + // Update local state to inject new member in team orbits dynamically + setTeams(prevTeams => + prevTeams.map(t => { + if (t.slug === assignModal.teamSlug) { + return { + ...t, + members: [...t.members, { login: assignModal.username, avatar_url: assignModal.avatar }] + } + } + return t + }) + ) + + setAssignModal(null) + } catch (err) { + console.error('Failed to assign team member:', err) + setAssignError(err.message || 'Action failed. Verify your account has administrator permission on this team.') + } finally { + setAssigning(false) + } + } + + // Visual layout checks + if (appLoading) return + if (!model) { + return ( +
+ +

Please select an organization on the homepage first...

+
+ ) + } + + return ( +
+
+ +
+ + + + + + {/* Warning if no PAT is set */} + {!pat && ( +
+ + No PAT token configured. Organization Team configurations are private and require an authenticated token to read or write. +
+ )} + + {loading ? ( +
+ +

Retrieving organization teams and relationships...

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : ( +
+ + {/* Left panel: Teams lists */} +
+
+ + Org Teams List + + {filteredTeams.length} + +
+ + setSearchQuery(e.target.value)} + style={{ ...C.input, width: '100%', padding: '6px 12px', fontSize: 12, marginBottom: 14 }} + /> + +
+ {filteredTeams.length === 0 ? ( +

No teams found.

+ ) : ( + filteredTeams.map(t => { + const active = selectedTeamSlug === t.slug + return ( +
setSelectedTeamSlug(active ? null : t.slug)} + style={{ + padding: 12, border: '1px solid var(--border)', borderRadius: 6, + cursor: 'pointer', background: active ? 'rgba(168,85,247,.08)' : 'transparent', + borderColor: active ? 'var(--purple)' : 'var(--border)', + transition: 'all 0.2s' + }} + className="hover:bg-(--surface2)" + > +
+ {t.name} + {t.privacy === 'secret' && } +
+ {t.description &&

{t.description}

} +
+ {t.members?.length || 0} Members + + {t.repos?.length || 0} Repos +
+
+ ) + }) + )} +
+
+ + {/* Right panel: D3 canvas force graph visualizer */} +
+ {/* Keyboard Assignment Helper for accessibility */} +
+ ACCESSIBILITY ASSIGNMENT: + + to + + +
+ + + + {/* D3 tooltip element */} + {tooltip && ( +
+
+ {tooltip.node.label} +
+
+ {tooltip.node.type} +
+ + {tooltip.node.type === 'team' && ( + <> +
+ {tooltip.node.data.description || 'No description provided.'} +
+
Members: {tooltip.node.data.members?.length || 0}
+
Repos: {tooltip.node.data.repos?.length || 0}
+ + )} + + {tooltip.node.type === 'member' && ( + <> +
Login: @{tooltip.node.data.login}
+ + GitHub profile + + + )} + + {tooltip.node.type === 'repo' && ( + <> +
Health Score: {tooltip.node.data.healthScore ?? 'Unknown'}
+
Forks: {tooltip.node.data.forks_count ?? 0}
+
Stars: {tooltip.node.data.stargazers_count ?? 0}
+ + )} +
+ )} + +
+ 🔮 Hexagon = Team | Circle = Contributor | Square = Repository + 👉 Drag & drop a contributor onto a team hexagon to update memberships visually +
+
+
+ )} + + {/* DND assign modal */} + {assignModal && ( +
+
+
+ + +
+ +

+ Are you sure you want to add @{assignModal.username} as a member of {assignModal.teamName}? +

+ +
+ {assignModal.username} +
+
{assignModal.username}
+
Adding to {assignModal.teamSlug}
+
+
+ + {assignError && ( +
+ + {assignError} +
+ )} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/services/github.js b/src/services/github.js index a4180fa..1aa328e 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -1,3 +1,5 @@ +import { computeHealthScore } from './analytics' + // IndexedDB Cache (L2) const DB_NAME = 'orgexplorer_cache' const STORE = 'cache' @@ -73,7 +75,14 @@ async function fetchWithCache(url, pat) { }) ) - if (res.status === 403) throw new Error('RATE_LIMIT') + if (res.status === 403) { + const remaining = res.headers.get('x-ratelimit-remaining') + const retryAfter = res.headers.get('retry-after') + if ((remaining !== null && Number(remaining) === 0) || retryAfter) { + throw new Error('RATE_LIMIT') + } + throw new Error('FORBIDDEN') + } if (res.status === 404) throw new Error('NOT_FOUND') if (!res.ok) throw new Error(`HTTP_${res.status}`) @@ -143,3 +152,117 @@ export async function fetchRateLimit(pat) { return data.rate } catch { return null } } + +export async function cacheDelete(key) { + try { + const db = await openDB() + return new Promise(res => { + const tx = db.transaction(STORE, 'readwrite') + tx.objectStore(STORE).delete(key) + tx.oncomplete = () => res(true) + tx.onerror = () => res(false) + }) + } catch { return false } +} + +function getPatHash(pat) { + if (!pat) return 'unauthenticated' + let hash = 0 + for (let i = 0; i < pat.length; i++) { + hash = (hash << 5) - hash + pat.charCodeAt(i) + hash |= 0 + } + return String(hash) +} + +async function fetchAuthenticatedWithCache(url, pat) { + const cacheKey = `${url}|${getPatHash(pat)}` + const cached = await cacheGet(cacheKey) + if (cached) return cached + + const headers = { Accept: 'application/vnd.github.v3+json' } + if (pat) headers.Authorization = `token ${pat}` + + const res = await fetch(url, { headers }) + + window.dispatchEvent( + new CustomEvent('rate-limit-update', { + detail: { + limit: Number(res.headers.get('x-ratelimit-limit')), + remaining: Number(res.headers.get('x-ratelimit-remaining')), + used: Number(res.headers.get('x-ratelimit-used')), + reset: Number(res.headers.get('x-ratelimit-reset')) + } + }) + ) + + if (res.status === 403) { + const remaining = res.headers.get('x-ratelimit-remaining') + const retryAfter = res.headers.get('retry-after') + if ((remaining !== null && Number(remaining) === 0) || retryAfter) { + throw new Error('RATE_LIMIT') + } + throw new Error('FORBIDDEN') + } + if (res.status === 404) throw new Error('NOT_FOUND') + if (!res.ok) throw new Error(`HTTP_${res.status}`) + + const data = await res.json() + cacheSet(cacheKey, data) + return data +} + +async function fetchAuthenticatedPaginated(baseUrl, pat) { + const all = [] + let page = 1 + while (true) { + const separator = baseUrl.includes('?') ? '&' : '?' + const url = `${baseUrl}${separator}per_page=100&page=${page}` + const data = await fetchAuthenticatedWithCache(url, pat) + if (!Array.isArray(data)) { + return data + } + all.push(...data) + if (data.length < 100) break + page++ + } + return all +} + +export const fetchOrgTeams = (org, pat) => + fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams`, pat) + +export const fetchTeamMembers = (org, teamSlug, pat) => + fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) + +export async function fetchTeamRepos(org, teamSlug, pat) { + const data = await fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) + if (Array.isArray(data)) { + return data.map(repo => ({ + ...repo, + healthScore: computeHealthScore(repo, 0) + })) + } + return data +} + +export async function updateTeamMembership(org, teamSlug, username, pat, role = 'member') { + if (!pat) throw new Error('Authentication (PAT) required to manage team memberships.') + const headers = { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${pat}`, + 'Content-Type': 'application/json' + } + const res = await fetch(`https://api.github.com/orgs/${org}/teams/${teamSlug}/memberships/${username}`, { + method: 'PUT', + headers, + body: JSON.stringify({ role }) + }) + if (res.status === 403) throw new Error('Permission denied. Admin/Write access required.') + if (!res.ok) throw new Error(`Failed to update membership (HTTP ${res.status})`) + + const cacheKey = `https://api.github.com/orgs/${org}/teams/${teamSlug}/members|${getPatHash(pat)}` + await cacheDelete(cacheKey) + + return true +}