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 ? (
+
+ ) : (
+
+
+ {/* 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 && (
+
+
+
+
+
Assign Team Membership
+
+
+
+ Are you sure you want to add @{assignModal.username} as a member of {assignModal.teamName}?
+
+
+
+

+
+
{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
+}