From 254d484eb25b2ef7fd64aff2bc000cb54ae47449 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Fri, 21 Aug 2026 15:39:08 +0530 Subject: [PATCH 1/3] feat: expand Governance Page to audit repository community files checklist --- src/context/AppContext.jsx | 32 ++++++---- src/pages/GovernancePage.jsx | 97 +++++++++++++++++++++++++++++-- src/pages/GovernancePage.test.jsx | 62 ++++++++++++++++++++ src/services/github.js | 8 +++ 4 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 src/pages/GovernancePage.test.jsx diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index a013159..fc45bc3 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 } 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,30 @@ 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 [issues, profile] = await Promise.all([ + fetchIssues(repo.orgLogin, repo.name, pat), + fetchCommunityProfile(repo.orgLogin, repo.name, pat) + ]) + issuesMap[key] = issues + 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 +213,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 +275,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 +291,7 @@ export function AppProvider({ children }) { ]) setIssuesData(issuesMap) + setCommunityData(communityMap) setPullsData(pullsMap) setGovLoading(false) setAdvanceAnalyticsLoading(false) @@ -323,7 +335,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,30 @@ 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 => { + 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 +113,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 +247,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 +403,63 @@ 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 (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..7def149 --- /dev/null +++ b/src/pages/GovernancePage.test.jsx @@ -0,0 +1,62 @@ +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 } + ] + }, + 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 + } + } + }, + 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', () => { + render() + + // Verify Community Files tab button displays with non-compliant count (1) + 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 name is rendered + expect(screen.getAllByText('repo-1').length).toBeGreaterThan(0) + + // Verify 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 missing files show red cross marks + const missingElements = screen.getAllByText(/✗ Missing/i) + expect(missingElements.length).toBe(3) // Contributing, Issue, PR Templates are missing + }) +}) diff --git a/src/services/github.js b/src/services/github.js index a4180fa..f014498 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -143,3 +143,11 @@ 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 null + } +} From 2342a99546afbf0cc0526e2f81642ad730445f6f Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Fri, 21 Aug 2026 20:08:57 +0530 Subject: [PATCH 2/3] refactor: address CodeRabbit review recommendations by mapping full org/repo keys, using independent Promise.allSettled and adding error fallback rendering --- src/context/AppContext.jsx | 6 +++--- src/pages/GovernancePage.jsx | 6 +++++- src/pages/GovernancePage.test.jsx | 22 ++++++++++++++++------ src/services/github.js | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index fc45bc3..d4cf821 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -171,12 +171,12 @@ export function AppProvider({ children }) { const batch = repos.slice(i, i + 5) await Promise.allSettled(batch.map(async repo => { const key = `${repo.orgLogin}/${repo.name}` - const [issues, profile] = await Promise.all([ + const [issuesResult, profileResult] = await Promise.allSettled([ fetchIssues(repo.orgLogin, repo.name, pat), fetchCommunityProfile(repo.orgLogin, repo.name, pat) ]) - issuesMap[key] = issues - communityMap[key] = profile + issuesMap[key] = issuesResult.status === 'fulfilled' ? issuesResult.value : [] + communityMap[key] = profileResult.status === 'fulfilled' ? profileResult.value : { error: true } })) } return { issuesMap, communityMap } diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx index 5cff759..2554811 100644 --- a/src/pages/GovernancePage.jsx +++ b/src/pages/GovernancePage.jsx @@ -79,6 +79,7 @@ export default function GovernancePage() { // 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 @@ -427,6 +428,9 @@ export default function GovernancePage() { 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 ( +
{item.repo}
{item.org}
diff --git a/src/pages/GovernancePage.test.jsx b/src/pages/GovernancePage.test.jsx index 7def149..1c2c7ac 100644 --- a/src/pages/GovernancePage.test.jsx +++ b/src/pages/GovernancePage.test.jsx @@ -7,7 +7,8 @@ vi.mock('../context/AppContext', () => ({ useApp: () => ({ model: { allRepos: [ - { id: 1, name: 'repo-1', orgLogin: 'AOSSIE-Org', license: null } + { id: 1, name: 'repo-1', orgLogin: 'AOSSIE-Org', license: null }, + { id: 2, name: 'repo-2', orgLogin: 'AOSSIE-Org', license: null } ] }, issuesData: {}, @@ -19,6 +20,9 @@ vi.mock('../context/AppContext', () => ({ issue_template: null, pull_request_template: null } + }, + 'AOSSIE-Org/repo-2': { + error: true } }, runAudit: vi.fn(), @@ -31,10 +35,11 @@ vi.mock('../context/AppContext', () => ({ })) describe('GovernancePage - Community Files tab', () => { - it('correctly calculates non-compliant community repos count and renders checklist table', () => { + 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() @@ -47,16 +52,21 @@ describe('GovernancePage - Community Files tab', () => { expect(screen.getByText('ISSUE TEMPLATES')).toBeInTheDocument() expect(screen.getByText('PR TEMPLATES')).toBeInTheDocument() - // Verify repository name is rendered + // Verify repository names are rendered expect(screen.getAllByText('repo-1').length).toBeGreaterThan(0) + expect(screen.getAllByText('repo-2').length).toBeGreaterThan(0) - // Verify Code of Conduct has a green check mark linking to GitHub + // 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 missing files show red cross marks + // Verify repo-1 missing files show red cross marks (3 of them) const missingElements = screen.getAllByText(/✗ Missing/i) - expect(missingElements.length).toBe(3) // Contributing, Issue, PR Templates are missing + 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 f014498..ab1e63e 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -148,6 +148,6 @@ export async function fetchCommunityProfile(org, repo, pat) { try { return await fetchWithCache(`https://api.github.com/repos/${org}/${repo}/community/profile`, pat) } catch { - return null + return { error: true } } } From 2e66bfb2e9beeae94da1080bd57ebc38709e7688 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Sun, 23 Aug 2026 12:30:14 +0530 Subject: [PATCH 3/3] fix: resolve template false negatives by querying template directories when community profile API fails to detect multiple files --- src/context/AppContext.jsx | 29 +++++++++++++++++++++++++++-- src/services/github.js | 16 ++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index d4cf821..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, fetchCommunityProfile } 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) @@ -176,7 +176,32 @@ export function AppProvider({ children }) { fetchCommunityProfile(repo.orgLogin, repo.name, pat) ]) issuesMap[key] = issuesResult.status === 'fulfilled' ? issuesResult.value : [] - communityMap[key] = profileResult.status === 'fulfilled' ? profileResult.value : { error: true } + 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 { issuesMap, communityMap } diff --git a/src/services/github.js b/src/services/github.js index ab1e63e..77667e4 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -151,3 +151,19 @@ export async function fetchCommunityProfile(org, repo, pat) { 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 + } +}