From 7d36902eb7e08607582b57c2d99bfb3e3a67e1b1 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Sun, 9 Aug 2026 22:53:16 +0530 Subject: [PATCH 1/7] feat: add Teams Explorer visual mapping and membership editor --- src/App.jsx | 2 + src/components/Navbar.jsx | 1 + src/pages/TeamsPage.jsx | 611 ++++++++++++++++++++++++++++++++++++++ src/services/github.js | 26 ++ 4 files changed, 640 insertions(+) create mode 100644 src/pages/TeamsPage.jsx 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..7627ccd --- /dev/null +++ b/src/pages/TeamsPage.jsx @@ -0,0 +1,611 @@ +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 [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 + + setLoading(true) + setError('') + + fetchOrgTeams(orgName, pat) + .then(async (fetchedTeams) => { + if (!fetchedTeams || !fetchedTeams.length) { + setTeams([]) + setLoading(false) + return + } + + // Fetch members and repos for each team in parallel batches + const enriched = await Promise.all( + fetchedTeams.map(async (team) => { + try { + const [members, repos] = await Promise.all([ + fetchTeamMembers(orgName, team.slug, pat).catch(() => []), + fetchTeamRepos(orgName, team.slug, pat).catch(() => []) + ]) + return { ...team, members, repos } + } catch { + return { ...team, members: [], repos: [] } + } + }) + ) + setTeams(enriched) + setLoading(false) + }) + .catch((err) => { + console.error('Failed to load org teams:', err) + setError(err.message === 'RATE_LIMIT' + ? 'GitHub rate limit exceeded. Please add a PAT in Settings.' + : 'Failed to load organization teams. Verify your Personal Access Token.' + ) + setLoading(false) + }) + }, [orgName, pat]) + + // 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)) { + // Color by composite health score or forks counts + const score = repo.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 + }) + } + 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 () => sim.stop() + }, [teams, searchQuery]) + + // 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 */} +
+ + + {/* 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 && ( +
+
+
+ +

Assign Team Membership

+
+ +

+ 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..7a1a746 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -143,3 +143,29 @@ export async function fetchRateLimit(pat) { return data.rate } catch { return null } } + +export const fetchOrgTeams = (org, pat) => + fetchWithCache(`https://api.github.com/orgs/${org}/teams`, pat) + +export const fetchTeamMembers = (org, teamSlug, pat) => + fetchWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) + +export const fetchTeamRepos = (org, teamSlug, pat) => + fetchWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) + +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})`) + return true +} From ea4c4fc2d15ac65d9a955f87a7539f12a15a6f8d Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 10 Aug 2026 11:48:28 +0530 Subject: [PATCH 2/7] fix: identify scope forbidden errors from rate limits in Teams Page loading --- src/pages/TeamsPage.jsx | 11 +++++++---- src/services/github.js | 8 +++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx index 7627ccd..978c22c 100644 --- a/src/pages/TeamsPage.jsx +++ b/src/pages/TeamsPage.jsx @@ -71,10 +71,13 @@ export default function TeamsPage() { }) .catch((err) => { console.error('Failed to load org teams:', err) - setError(err.message === 'RATE_LIMIT' - ? 'GitHub rate limit exceeded. Please add a PAT in Settings.' - : 'Failed to load organization teams. Verify your Personal Access Token.' - ) + 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) }) }, [orgName, pat]) diff --git a/src/services/github.js b/src/services/github.js index 7a1a746..fbb5fde 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -73,7 +73,13 @@ 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') + if (remaining !== null && Number(remaining) === 0) { + 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}`) From 9b7919df1480473dfdb5144d441cc77e05f62245 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 10 Aug 2026 12:44:53 +0530 Subject: [PATCH 3/7] refactor: resolve all bot review feedback on teams explorer cache management and keyboard accessibility --- src/pages/TeamsPage.jsx | 116 ++++++++++++++++++++++++++++++++++------ src/services/github.js | 72 +++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 21 deletions(-) diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx index 978c22c..4b58367 100644 --- a/src/pages/TeamsPage.jsx +++ b/src/pages/TeamsPage.jsx @@ -41,35 +41,55 @@ export default function TeamsPage() { useEffect(() => { if (!orgName) 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 parallel batches - const enriched = await Promise.all( - fetchedTeams.map(async (team) => { - try { - const [members, repos] = await Promise.all([ - fetchTeamMembers(orgName, team.slug, pat).catch(() => []), - fetchTeamRepos(orgName, team.slug, pat).catch(() => []) - ]) - return { ...team, members, repos } - } catch { - return { ...team, members: [], repos: [] } - } - }) - ) + // 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.') @@ -80,6 +100,10 @@ export default function TeamsPage() { } setLoading(false) }) + + return () => { + ignore = true + } }, [orgName, pat]) // Filtered teams list based on search @@ -142,16 +166,23 @@ export default function TeamsPage() { team.repos.forEach(repo => { const repoId = `repo:${repo.name}` if (!nodesMap.has(repoId)) { - // Color by composite health score or forks counts - const score = repo.healthScore ?? 65 + // Look up in the analytical model totalRepos list to resolve computed scores + const modelRepo = model?.totalRepos?.find(r => r.name === repo.name) + const score = modelRepo?.healthScore ?? repo.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 + 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 }) @@ -498,6 +529,57 @@ export default function TeamsPage() { {/* Right panel: D3 canvas force graph visualizer */}
+ {/* Keyboard Assignment Helper for accessibility */} +
+ ACCESSIBILITY ASSIGNMENT: + + to + + +
+ {/* D3 tooltip element */} diff --git a/src/services/github.js b/src/services/github.js index fbb5fde..f86392f 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -75,7 +75,8 @@ async function fetchWithCache(url, pat) { if (res.status === 403) { const remaining = res.headers.get('x-ratelimit-remaining') - if (remaining !== null && Number(remaining) === 0) { + const retryAfter = res.headers.get('retry-after') + if ((remaining !== null && Number(remaining) === 0) || retryAfter) { throw new Error('RATE_LIMIT') } throw new Error('FORBIDDEN') @@ -150,14 +151,73 @@ export async function fetchRateLimit(pat) { } 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 +} + export const fetchOrgTeams = (org, pat) => - fetchWithCache(`https://api.github.com/orgs/${org}/teams`, pat) + fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams`, pat) export const fetchTeamMembers = (org, teamSlug, pat) => - fetchWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) + fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) export const fetchTeamRepos = (org, teamSlug, pat) => - fetchWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) + fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) export async function updateTeamMembership(org, teamSlug, username, pat, role = 'member') { if (!pat) throw new Error('Authentication (PAT) required to manage team memberships.') @@ -173,5 +233,9 @@ export async function updateTeamMembership(org, teamSlug, username, pat, 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 } From 7e876345ce55b639ac0ccee65a46c929cb7bb9ac Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 10 Aug 2026 13:18:15 +0530 Subject: [PATCH 4/7] feat: enrich team repos with healthScore in fetchTeamRepos and align priority rendering in TeamsPage --- src/pages/TeamsPage.jsx | 2 +- src/services/github.js | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx index 4b58367..95f67e0 100644 --- a/src/pages/TeamsPage.jsx +++ b/src/pages/TeamsPage.jsx @@ -168,7 +168,7 @@ export default function TeamsPage() { 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 = modelRepo?.healthScore ?? repo.healthScore ?? 65 + const score = repo.healthScore ?? modelRepo?.healthScore ?? 65 const healthColor = score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444' nodesMap.set(repoId, { diff --git a/src/services/github.js b/src/services/github.js index f86392f..6af737e 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' @@ -216,8 +218,16 @@ export const fetchOrgTeams = (org, pat) => export const fetchTeamMembers = (org, teamSlug, pat) => fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) -export const fetchTeamRepos = (org, teamSlug, pat) => - fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) +export async function fetchTeamRepos(org, teamSlug, pat) { + const data = await fetchAuthenticatedWithCache(`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.') From ce9ec764182c8dd62328110b9cf22f28c43072ad Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 10 Aug 2026 13:27:03 +0530 Subject: [PATCH 5/7] refactor: implement accessible focus-trapping dialog, pagination list aggregation, and require pat hook loading --- src/pages/TeamsPage.jsx | 88 +++++++++++++++++++++++++++++++++++++---- src/services/github.js | 23 +++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx index 95f67e0..0ad3ed4 100644 --- a/src/pages/TeamsPage.jsx +++ b/src/pages/TeamsPage.jsx @@ -25,6 +25,7 @@ export default function TeamsPage() { // 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 @@ -40,6 +41,12 @@ export default function TeamsPage() { // Lazy load organization teams useEffect(() => { if (!orgName) return + if (!pat) { + setTeams([]) + setLoading(false) + setError('') + return + } let ignore = false setLoading(true) @@ -106,6 +113,61 @@ export default function TeamsPage() { } }, [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 => @@ -374,8 +436,10 @@ export default function TeamsPage() { node.attr('transform', d => `translate(${d.x},${d.y})`) }) - return () => sim.stop() - }, [teams, searchQuery]) + return () => { + if (simRef.current) simRef.current.stop() + } + }, [teams, searchQuery, model, appLoading]) // Trigger Team Membership Assignment const handleAssignMembership = async () => { @@ -643,14 +707,22 @@ export default function TeamsPage() { display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}> -
+
-

Assign Team Membership

+

diff --git a/src/services/github.js b/src/services/github.js index 6af737e..1aa328e 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -212,14 +212,31 @@ async function fetchAuthenticatedWithCache(url, pat) { 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) => - fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams`, pat) + fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams`, pat) export const fetchTeamMembers = (org, teamSlug, pat) => - fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) + fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams/${teamSlug}/members`, pat) export async function fetchTeamRepos(org, teamSlug, pat) { - const data = await fetchAuthenticatedWithCache(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) + const data = await fetchAuthenticatedPaginated(`https://api.github.com/orgs/${org}/teams/${teamSlug}/repos`, pat) if (Array.isArray(data)) { return data.map(repo => ({ ...repo, From f21220b96352be84f94537576f65bee3095bca6e Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Tue, 11 Aug 2026 11:36:00 +0530 Subject: [PATCH 6/7] feat: add load sandbox demo data option to allow non-member verification and screenshots --- src/pages/TeamsPage.jsx | 107 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/src/pages/TeamsPage.jsx b/src/pages/TeamsPage.jsx index 0ad3ed4..3eb73a8 100644 --- a/src/pages/TeamsPage.jsx +++ b/src/pages/TeamsPage.jsx @@ -113,6 +113,55 @@ export default function TeamsPage() { } }, [orgName, pat]) + const loadDemoData = () => { + setError('') + setTeams([ + { + name: 'Core Maintainers', + slug: 'core-maintainers', + description: 'Primary architects and maintainers of AOSSIE repositories.', + privacy: 'closed', + members: [ + { login: 'AbiramiR-27', avatar_url: 'https://github.com/identicons/abirami.png' }, + { login: 'm-samran', avatar_url: 'https://github.com/identicons/samran.png' }, + { login: 'aossie-bot', avatar_url: 'https://github.com/identicons/bot.png' } + ], + repos: [ + { name: 'OrgExplorer', healthScore: 92, stargazers_count: 154, forks_count: 42 }, + { name: 'Social-Street-Smart', healthScore: 84, stargazers_count: 85, forks_count: 21 } + ] + }, + { + name: 'GSoC Developers', + slug: 'gsoc-developers', + description: 'Google Summer of Code contributors and developers.', + privacy: 'closed', + members: [ + { login: 'AbiramiR-27', avatar_url: 'https://github.com/identicons/abirami.png' }, + { login: 'gsoc-student-1', avatar_url: 'https://github.com/identicons/student1.png' }, + { login: 'gsoc-student-2', avatar_url: 'https://github.com/identicons/student2.png' } + ], + repos: [ + { name: 'OrgExplorer', healthScore: 92, stargazers_count: 154, forks_count: 42 }, + { name: 'AOSSIE-Website', healthScore: 78, stargazers_count: 32, forks_count: 10 } + ] + }, + { + name: 'Documentation Team', + slug: 'documentation-team', + description: 'Technical writers and editors managing outreach documentation.', + privacy: 'closed', + members: [ + { login: 'doc-writer-xyz', avatar_url: 'https://github.com/identicons/writer.png' }, + { login: 'gsoc-student-1', avatar_url: 'https://github.com/identicons/student1.png' } + ], + repos: [ + { name: 'AOSSIE-Website', healthScore: 78, stargazers_count: 32, forks_count: 10 } + ] + } + ]) + } + // Modal key listeners and keyboard focus trap useEffect(() => { if (!assignModal) return @@ -176,6 +225,18 @@ export default function TeamsPage() { ) }, [teams, searchQuery]) + // Helper to compile a fallback list of contributors if model is empty (e.g. sandbox demo mode) + const allContributors = useMemo(() => { + if (model?.contributors?.length) return model.contributors + const unique = new Map() + teams.forEach(t => { + t.members?.forEach(m => { + unique.set(m.login, m) + }) + }) + return Array.from(unique.values()) + }, [model, teams]) + // Generate D3 Force Graph Nodes and Links useEffect(() => { if (!svgRef.current || teams.length === 0) return @@ -513,15 +574,25 @@ export default function TeamsPage() { onRun={runFullExplore} /> + {/* Warning if no PAT is set */} {/* 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. +
+ + No PAT token configured. Organization Team configurations are private and require an authenticated token to read or write. +
+
)} @@ -531,9 +602,18 @@ export default function TeamsPage() {

Retrieving organization teams and relationships...

) : error ? ( -
- -

{error}

+
+ +
+

{error}

+

Organization teams require private member scopes. You can load demo sandbox data to preview the visual graph.

+
+
) : (
@@ -558,7 +638,16 @@ export default function TeamsPage() {
{filteredTeams.length === 0 ? ( -

No teams found.

+
+

No teams found.

+ +
) : ( filteredTeams.map(t => { const active = selectedTeamSlug === t.slug @@ -602,7 +691,7 @@ export default function TeamsPage() { style={{ ...C.input, padding: '4px 8px', fontSize: 11, width: 140, background: 'var(--surface)' }} > - {(model?.contributors || []).map(c => )} + {(allContributors || []).map(c => )} to