Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 47 additions & 10 deletions src/context/AppContext.jsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -86,6 +87,7 @@ export function AppProvider({ children }) {
setModel(null);
setOrgs([]);
setIssuesData({});
setCommunityData({});
setLastOrgNames(orgNames);
setAuditComplete(false);
setAdvanceAnalyticsComplete(false);
Expand Down Expand Up @@ -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
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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])
Expand All @@ -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])
Expand Down Expand Up @@ -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)
Expand All @@ -280,6 +316,7 @@ export function AppProvider({ children }) {
])

setIssuesData(issuesMap)
setCommunityData(communityMap)
setPullsData(pullsMap)
setGovLoading(false)
setAdvanceAnalyticsLoading(false)
Expand Down Expand Up @@ -323,7 +360,7 @@ export function AppProvider({ children }) {

return (
<Ctx.Provider value={{
pat, savePat, orgs, model, issuesData, pullsData,
pat, savePat, orgs, model, issuesData, pullsData, communityData,
rateLimit, loading, loadMsg, govLoading, error, totalRepo,
runAdvanceAnalytics, refreshRateLimit, advanceAnalyticsLoading, advanceAnalyticsComplete,
runFullAnalytics,
Expand Down
101 changes: 96 additions & 5 deletions src/pages/GovernancePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { GovernanceSkeleton } from '../components/Orgexplorerskeletons'

const TABS = [
{ key: 'dead', label: 'Dead Issues' },
{ key: 'zombie', label: 'Zombie PRs' },
{ key: 'stale', label: 'Stale Issues Ratio' },
{ key: 'zombie', label: 'Zombie PRs' },
{ key: 'license', label: 'No License' },
{ key: 'community', label: 'Community Files' },
]

const getStatus = ratio => {
Expand Down Expand Up @@ -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
Expand All @@ -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 <GovernanceSkeleton />
if (!model) return null

Expand All @@ -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 }) => (
Expand Down Expand Up @@ -222,8 +248,13 @@ export default function GovernancePage() {
}}
>
{t.label}{' '}
<span style={{ color: counts[t.key] > 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
<span style={{
color: t.key === 'stale'
? (Number(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]}
</span>
</button>
))}
Expand Down Expand Up @@ -373,6 +404,66 @@ export default function GovernancePage() {
</div>
) : <EmptyOk msg="All repos have licenses" sub="Good compliance across the portfolio." />
)}

{/* Community Files */}
{tab === 'community' && (
communityRepos.length ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
{['REPOSITORY', 'CODE OF CONDUCT', 'CONTRIBUTING', 'ISSUE TEMPLATES', 'PR TEMPLATES'].map(h => (
<th key={h} style={{ padding: '8px 14px', textAlign: 'left', fontSize: 11, color: 'var(--text2)', fontWeight: 600, borderBottom: '1px solid var(--border)', background: 'var(--surface2)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{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 <span style={{ color: 'var(--text3)', fontSize: 12 }}>Unable to assess</span>
}
if (fileObj && fileObj.html_url) {
return (
<a
href={fileObj.html_url}
target="_blank"
rel="noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--green)', fontSize: 12, fontWeight: 500 }}
>
✓ Yes
</a>
)
}
return <span style={{ color: 'var(--red)', fontSize: 12, fontWeight: 500 }}>✗ Missing</span>
}

return (
<tr key={`${item.org}/${item.repo}`} style={{ borderBottom: '1px solid var(--border)', background: i % 2 ? 'var(--surface2)' : 'transparent' }}>
<td style={{ padding: '12px 14px' }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{item.repo}</div>
<div style={{ fontSize: 11, color: 'var(--text2)' }}>{item.org}</div>
</td>
<td style={{ padding: '12px 14px' }}>{renderCell(coc)}</td>
<td style={{ padding: '12px 14px' }}>{renderCell(contributing)}</td>
<td style={{ padding: '12px 14px' }}>{renderCell(issue)}</td>
<td style={{ padding: '12px 14px' }}>{renderCell(pr)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : <EmptyOk msg="No community profile data" sub="Run the audit to fetch repository community health files." />
)}
</div>
</div>
)
Expand Down
72 changes: 72 additions & 0 deletions src/pages/GovernancePage.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<GovernancePage />)

// 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)
})
})
24 changes: 24 additions & 0 deletions src/services/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading