diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index a013159..08816c7 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react' -import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github' +import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls, fetchCommunityProfile, fetchIssueTemplatesDirectory, fetchPRTemplatesDirectory } from '../services/github' import { buildAnalyticalModel, getTopRepositories } from '../services/analytics' const Ctx = createContext(null) @@ -29,6 +29,7 @@ export function AppProvider({ children }) { const [orgs, setOrgs] = useState([]) const [model, setModel] = useState(null) const [issuesData, setIssuesData] = useState({}) + const [communityData, setCommunityData] = useState({}) const [pullsData, setPullsData] = useState({}) const [rateLimit, setRateLimit] = useState(getStoredRateLimit) const [loading, setLoading] = useState(false) @@ -86,6 +87,7 @@ export function AppProvider({ children }) { setModel(null); setOrgs([]); setIssuesData({}); + setCommunityData({}); setLastOrgNames(orgNames); setAuditComplete(false); setAdvanceAnalyticsComplete(false); @@ -163,22 +165,55 @@ export function AppProvider({ children }) { const auditRepos = useCallback(async (allRepos) => { const repos = selectAnalysisRepos(allRepos) - const map = {} + const issuesMap = {} + const communityMap = {} for (let i = 0; i < repos.length; i += 5) { const batch = repos.slice(i, i + 5) await Promise.allSettled(batch.map(async repo => { - map[`${repo.orgLogin}/${repo.name}`] = await fetchIssues(repo.orgLogin, repo.name, pat) + const key = `${repo.orgLogin}/${repo.name}` + const [issuesResult, profileResult] = await Promise.allSettled([ + fetchIssues(repo.orgLogin, repo.name, pat), + fetchCommunityProfile(repo.orgLogin, repo.name, pat) + ]) + issuesMap[key] = issuesResult.status === 'fulfilled' ? issuesResult.value : [] + const profile = profileResult.status === 'fulfilled' ? profileResult.value : { error: true } + + if (profile && !profile.error && profile.files) { + if (!profile.files.issue_template) { + try { + const templates = await fetchIssueTemplatesDirectory(repo.orgLogin, repo.name, pat) + if (templates && Array.isArray(templates) && templates.length > 0) { + profile.files.issue_template = { + html_url: `https://github.com/${repo.orgLogin}/${repo.name}/tree/${repo.default_branch || 'main'}/.github/ISSUE_TEMPLATE` + } + } + } catch (e) {} + } + if (!profile.files.pull_request_template) { + try { + const prTemplates = await fetchPRTemplatesDirectory(repo.orgLogin, repo.name, pat) + if (prTemplates && Array.isArray(prTemplates) && prTemplates.length > 0) { + profile.files.pull_request_template = { + html_url: `https://github.com/${repo.orgLogin}/${repo.name}/tree/${repo.default_branch || 'main'}/.github/PULL_REQUEST_TEMPLATE` + } + } + } catch (e) {} + } + } + + communityMap[key] = profile })) } - return map + return { issuesMap, communityMap } }, [pat, selectAnalysisRepos]) // Governance audit : used directly when repos are already complete const runAudit = useCallback(async () => { if (!model || govLoading) return setGovLoading(true) - const map = await auditRepos(model.allRepos) - setIssuesData(map) + const { issuesMap, communityMap } = await auditRepos(model.allRepos) + setIssuesData(issuesMap) + setCommunityData(communityMap) setGovLoading(false) setAuditComplete(!!pat) }, [model, pat, govLoading, auditRepos]) @@ -203,8 +238,9 @@ export function AppProvider({ children }) { if (!currentModel) return setGovLoading(true) - const map = await auditRepos(currentModel.allRepos) - setIssuesData(map) + const { issuesMap, communityMap } = await auditRepos(currentModel.allRepos) + setIssuesData(issuesMap) + setCommunityData(communityMap) setGovLoading(false) setAuditComplete(!!pat) }, [isComplete, model, runFullExplore, auditRepos, pat, govLoading]) @@ -264,7 +300,7 @@ export function AppProvider({ children }) { setGovLoading(true) setAdvanceAnalyticsLoading(true) - const [issuesMap, pullsMap] = await Promise.all([ + const [{ issuesMap, communityMap }, pullsMap] = await Promise.all([ auditRepos(currentModel.allRepos), (async () => { const repos = selectAnalysisRepos(currentModel.totalRepos) @@ -280,6 +316,7 @@ export function AppProvider({ children }) { ]) setIssuesData(issuesMap) + setCommunityData(communityMap) setPullsData(pullsMap) setGovLoading(false) setAdvanceAnalyticsLoading(false) @@ -323,7 +360,7 @@ export function AppProvider({ children }) { return ( { @@ -42,7 +43,7 @@ const getStatus = ratio => { } export default function GovernancePage() { - const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis,staleRepoStats } = useApp() + const { model, issuesData, communityData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis, staleRepoStats } = useApp() const [tab, setTab] = useState('dead') const ITEMS_PER_PAGE = 10 @@ -63,6 +64,31 @@ export default function GovernancePage() { return arr }, [issuesData]) + // Get all repos audited for community profile + const communityRepos = useMemo(() => { + const arr = [] + Object.entries(communityData || {}).forEach(([key, profile]) => { + const [org, repo] = key.split('/') + if (profile) { + arr.push({ org, repo, profile }) + } + }) + return arr + }, [communityData]) + + // Count of non-compliant repos (missing at least one of CoC, Contributing, Issue Template, PR Template) + const nonCompliantCommunityCount = useMemo(() => { + return communityRepos.filter(item => { + if (item.profile && item.profile.error) return false + const files = item.profile.files || {} + const coc = files.code_of_conduct || files.code_of_conduct_file + const contributing = files.contributing + const issue = files.issue_template + const pr = files.pull_request_template + return !coc || !contributing || !issue || !pr + }).length + }, [communityRepos]) + if(loading) return if (!model) return null @@ -88,7 +114,7 @@ export default function GovernancePage() { // Issue resolution rate per repo const topRepos = model.allRepos.slice(0, 8) - const counts = { dead: deadIssues.length, zombie: zombiePRs.length, license: noLicense.length, stale: staleIssuesRatio.toFixed(2) } + const counts = { dead: deadIssues.length, zombie: zombiePRs.length, license: noLicense.length, stale: staleIssuesRatio.toFixed(2), community: nonCompliantCommunityCount } // Stat card const StatBox = ({ label, value, sub, color }) => ( @@ -222,8 +248,13 @@ export default function GovernancePage() { }} > {t.label}{' '} - 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}> - {counts[t.key]} + 40 ? 'var(--red)' : 'var(--green)') + : (counts[t.key] > 0 ? 'var(--red)' : 'var(--green)'), + marginLeft: 4 + }}> + {t.key === 'stale' ? `${counts[t.key]}%` : counts[t.key]} ))} @@ -373,6 +404,66 @@ export default function GovernancePage() { ) : )} + + {/* Community Files */} + {tab === 'community' && ( + communityRepos.length ? ( +
+ + + + {['REPOSITORY', 'CODE OF CONDUCT', 'CONTRIBUTING', 'ISSUE TEMPLATES', 'PR TEMPLATES'].map(h => ( + + ))} + + + + {communityRepos.map((item, i) => { + const files = item.profile.files || {} + const coc = files.code_of_conduct || files.code_of_conduct_file + const contributing = files.contributing + const issue = files.issue_template + const pr = files.pull_request_template + + const renderCell = (fileObj) => { + if (item.profile && item.profile.error) { + return Unable to assess + } + if (fileObj && fileObj.html_url) { + return ( + + ✓ Yes + + ) + } + return ✗ Missing + } + + return ( + + + + + + + + ) + })} + +
+ {h} +
+
{item.repo}
+
{item.org}
+
{renderCell(coc)}{renderCell(contributing)}{renderCell(issue)}{renderCell(pr)}
+
+ ) : + )} ) diff --git a/src/pages/GovernancePage.test.jsx b/src/pages/GovernancePage.test.jsx new file mode 100644 index 0000000..1c2c7ac --- /dev/null +++ b/src/pages/GovernancePage.test.jsx @@ -0,0 +1,72 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect } from 'vitest' +import GovernancePage from './GovernancePage' + +vi.mock('../context/AppContext', () => ({ + useApp: () => ({ + model: { + allRepos: [ + { id: 1, name: 'repo-1', orgLogin: 'AOSSIE-Org', license: null }, + { id: 2, name: 'repo-2', orgLogin: 'AOSSIE-Org', license: null } + ] + }, + issuesData: {}, + communityData: { + 'AOSSIE-Org/repo-1': { + files: { + code_of_conduct: { html_url: 'https://github.com/AOSSIE-Org/repo-1/blob/main/CODE_OF_CONDUCT.md' }, + contributing: null, + issue_template: null, + pull_request_template: null + } + }, + 'AOSSIE-Org/repo-2': { + error: true + } + }, + runAudit: vi.fn(), + govLoading: false, + auditComplete: true, + loading: false, + runGovernanceAnalysis: vi.fn(), + staleRepoStats: [] + }) +})) + +describe('GovernancePage - Community Files tab', () => { + it('correctly calculates non-compliant community repos count and renders checklist table with fail fallback status', () => { + render() + + // Verify Community Files tab button displays with non-compliant count (1) + // repo-1 is missing files (1 non-compliant). repo-2 is an error, so it's excluded from calculation. + const communityTabButton = screen.getByRole('button', { name: /Community Files\s+1/i }) + expect(communityTabButton).toBeInTheDocument() + + // Click the Community Files tab + fireEvent.click(communityTabButton) + + // Verify the table headers render correctly + expect(screen.getByText('CODE OF CONDUCT')).toBeInTheDocument() + expect(screen.getByText('CONTRIBUTING')).toBeInTheDocument() + expect(screen.getByText('ISSUE TEMPLATES')).toBeInTheDocument() + expect(screen.getByText('PR TEMPLATES')).toBeInTheDocument() + + // Verify repository names are rendered + expect(screen.getAllByText('repo-1').length).toBeGreaterThan(0) + expect(screen.getAllByText('repo-2').length).toBeGreaterThan(0) + + // Verify repo-1 Code of Conduct has a green check mark linking to GitHub + const cocLink = screen.getByRole('link', { name: /✓ Yes/i }) + expect(cocLink).toBeInTheDocument() + expect(cocLink.getAttribute('href')).toBe('https://github.com/AOSSIE-Org/repo-1/blob/main/CODE_OF_CONDUCT.md') + + // Verify repo-1 missing files show red cross marks (3 of them) + const missingElements = screen.getAllByText(/✗ Missing/i) + expect(missingElements.length).toBe(3) + + // Verify repo-2 displays "Unable to assess" status (4 of them) + const errorElements = screen.getAllByText(/Unable to assess/i) + expect(errorElements.length).toBe(4) + }) +}) diff --git a/src/services/github.js b/src/services/github.js index a4180fa..77667e4 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -143,3 +143,27 @@ export async function fetchRateLimit(pat) { return data.rate } catch { return null } } + +export async function fetchCommunityProfile(org, repo, pat) { + try { + return await fetchWithCache(`https://api.github.com/repos/${org}/${repo}/community/profile`, pat) + } catch { + return { error: true } + } +} + +export async function fetchIssueTemplatesDirectory(org, repo, pat) { + try { + return await fetchWithCache(`https://api.github.com/repos/${org}/${repo}/contents/.github/ISSUE_TEMPLATE`, pat) + } catch { + return null + } +} + +export async function fetchPRTemplatesDirectory(org, repo, pat) { + try { + return await fetchWithCache(`https://api.github.com/repos/${org}/${repo}/contents/.github/PULL_REQUEST_TEMPLATE`, pat) + } catch { + return null + } +}