Skip to content
Merged
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
40 changes: 37 additions & 3 deletions src/context/AppContext.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'
import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, } from '../services/github'
import { buildAnalyticalModel, getTopRepositories } from '../services/analytics'

Expand Down Expand Up @@ -129,7 +129,7 @@ export function AppProvider({ children }) {
if (!model || govLoading) return
setGovLoading(true)
const map = {}
const repos = pat? model.allRepos : model.allRepos.slice(0, 15)
const repos = pat? model.totalRepos : model.totalRepos.slice(0, 15)

// Batches of 5 using Promise.allSettled
for (let i = 0; i < repos.length; i += 5) {
Expand All @@ -142,11 +142,45 @@ export function AppProvider({ children }) {
setGovLoading(false)
}, [model, pat, govLoading])

const STALE_DAYS = 90

const staleRepoStats = useMemo(() => {
const now = Date.now()

return Object.entries(issuesData || {}).map(([key, issues]) => {
const [org, repo] = key.split('/')

const normalIssues = issues.filter(i => !i.pull_request)

const openIssues = normalIssues.filter(i => i.state === 'open')

const staleIssues = openIssues.filter(i => {
const updated = new Date(i.updated_at).getTime()
const diffDays = (now - updated) / (1000 * 60 * 60 * 24)
return diffDays >= STALE_DAYS
})

const ratio =
openIssues.length === 0
? 0
: Math.round((staleIssues.length / openIssues.length) * 100)

return {
id: key,
org,
repo,
ratio,
staleCount: staleIssues.length,
openCount: openIssues.length
}
}).sort((a, b) => b.ratio - a.ratio)
}, [issuesData])

return (
<Ctx.Provider value={{
pat, savePat, orgs, model, issuesData,
rateLimit, loading, loadMsg, govLoading, error, totalRepo,
explore, runAudit, setError,
explore, runAudit, setError, staleRepoStats,
}}>
Comment on lines 179 to 184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Memoize the context value to avoid re-rendering every consumer.

The Ctx.Provider value object is reconstructed on every render of AppProvider, so every consumer (e.g. GovernancePage) re-renders even when unrelated state changes. Since useMemo is already imported for staleRepoStats, wrapping the provider value is a low-effort follow-up.

♻️ Suggested fix
+  const ctxValue = useMemo(() => ({
+    pat, savePat, orgs, model, issuesData,
+    rateLimit, loading, loadMsg, govLoading, error, totalRepo,
+    explore, runAudit, setError, staleRepoStats,
+  }), [pat, savePat, orgs, model, issuesData, rateLimit, loading, loadMsg, govLoading, error, totalRepo, explore, runAudit, staleRepoStats])
+
   return (
-    <Ctx.Provider value={{
-      pat, savePat, orgs, model, issuesData,
-      rateLimit, loading, loadMsg, govLoading, error, totalRepo,
-      explore, runAudit, setError, staleRepoStats,
-    }}>
+    <Ctx.Provider value={ctxValue}>
       {children}
     </Ctx.Provider>
   )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<Ctx.Provider value={{
pat, savePat, orgs, model, issuesData,
rateLimit, loading, loadMsg, govLoading, error, totalRepo,
explore, runAudit, setError,
explore, runAudit, setError, staleRepoStats,
}}>
const ctxValue = useMemo(() => ({
pat, savePat, orgs, model, issuesData,
rateLimit, loading, loadMsg, govLoading, error, totalRepo,
explore, runAudit, setError, staleRepoStats,
}), [pat, savePat, orgs, model, issuesData, rateLimit, loading, loadMsg, govLoading, error, totalRepo, explore, runAudit, staleRepoStats])
return (
<Ctx.Provider value={ctxValue}>
{children}
</Ctx.Provider>
)
🧰 Tools
🪛 React Doctor (0.5.8)

[warning] 180-180: Every reader of this context redraws on each render because you build its value inline.

Wrap the context value in useMemo or move it outside the component so consumers do not redraw every render.

(jsx-no-constructed-context-values)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/AppContext.jsx` around lines 179 - 184, The Ctx.Provider value in
AppProvider is rebuilt on every render, which forces all consumers such as
GovernancePage to re-render unnecessarily. Memoize the provider value object
with useMemo and include all referenced fields and callbacks used in the value
prop. Keep the memoized object near the existing staleRepoStats memoization so
the context only changes when one of its dependencies changes.

Source: Linters/SAST tools

{children}
</Ctx.Provider>
Expand Down
183 changes: 147 additions & 36 deletions src/pages/GovernancePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,52 @@ import { C, PageTitle, EmptyOk } from '../components/UI'
const TABS = [
{ key: 'dead', label: 'Dead Issues' },
{ key: 'zombie', label: 'Zombie PRs' },
{ key: 'risky', label: 'Risky Repos' },
{ key: 'stale', label: 'Stale Issues Ratio' },
{ key: 'license', label: 'No License' },
]

const getStatus = ratio => {
if (ratio <= 10)
return {
label: 'Excellent',
color: 'var(--green)',
bg: 'rgba(34,197,94,.12)'
}

if (ratio <= 25)
return {
label: 'Healthy',
color: 'var(--amber)',
bg: 'rgba(250,204,21,.12)'
}

if (ratio <= 40)
return {
label: 'Warning',
color: 'var(--orange)',
bg: 'rgba(251,146,60,.12)'
}

return {
label: 'Critical',
color: 'var(--red)',
bg: 'rgba(239,68,68,.12)'
}
}

export default function GovernancePage() {
const { model, issuesData, runAudit, govLoading } = useApp()
const { model, issuesData, runAudit, govLoading, staleRepoStats } = useApp()
const [tab, setTab] = useState('dead')

const ITEMS_PER_PAGE = 10
const [stalePage, setStalePage] = useState(1)
const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE)

const paginatedStaleRepos = useMemo(() => {
const start = (stalePage - 1) * ITEMS_PER_PAGE
return staleRepoStats.slice(start, start + ITEMS_PER_PAGE)
}, [staleRepoStats, stalePage])
Comment on lines +46 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

stalePage isn't reset when staleRepoStats shrinks.

If a re-run audit yields fewer repos, stalePage can remain past totalPages, showing an empty page with "Page X of Y" where X > Y (Next stays enabled since stalePage === totalPages never holds).

🐛 Suggested fix
+  useEffect(() => {
+    setStalePage(1)
+  }, [staleRepoStats])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ITEMS_PER_PAGE = 10
const [stalePage, setStalePage] = useState(1)
const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE)
const paginatedStaleRepos = useMemo(() => {
const start = (stalePage - 1) * ITEMS_PER_PAGE
return staleRepoStats.slice(start, start + ITEMS_PER_PAGE)
}, [staleRepoStats, stalePage])
const ITEMS_PER_PAGE = 10
const [stalePage, setStalePage] = useState(1)
const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE)
useEffect(() => {
setStalePage(1)
}, [staleRepoStats])
const paginatedStaleRepos = useMemo(() => {
const start = (stalePage - 1) * ITEMS_PER_PAGE
return staleRepoStats.slice(start, start + ITEMS_PER_PAGE)
}, [staleRepoStats, stalePage])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/GovernancePage.jsx` around lines 46 - 53, The pagination state in
GovernancePage.jsx is not being clamped when staleRepoStats changes, so
stalePage can stay greater than totalPages and show an empty view. Update the
GovernancePage component to reset or clamp stalePage whenever staleRepoStats
shrinks, using the stalePage state, totalPages calculation, and the
paginatedStaleRepos useMemo as the key places to adjust the behavior so the
current page always stays within bounds.

console.log(paginatedStaleRepos);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove leftover debug console.log.

🧹 Suggested fix
-  console.log(paginatedStaleRepos);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
console.log(paginatedStaleRepos);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/GovernancePage.jsx` at line 54, The GovernancePage component still
contains a leftover debug console.log in the stale repos pagination flow. Remove
the console.log from GovernancePage.jsx so the page no longer emits
paginatedStaleRepos to the console, keeping the render logic and pagination
behavior unchanged.

// Flatten all issues and tag with repo/org
const allIssues = useMemo(() => {
const arr = []
Expand All @@ -34,28 +72,21 @@ export default function GovernancePage() {
.filter(i => !i.pull_request && daysSince(i.created_at) >= 90)
.sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at))

// Health check 2 — Zombie PRs (>90 days open)
// Health check 2 — Percentage of dead issues relative to all issues
const staleIssuesRatio = allIssues.length ? (deadIssues.length / allIssues.length) * 100 : 0;
Comment on lines +75 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

"Stale Issues Ratio" stat card shows the wrong metric.

staleIssuesRatio is computed as dead issues (created_at ≥ 90 days) divided by all issues — the same definition used for the "Dead Issues" health check, not the new stale-issue metric (updated_at inactivity) computed as staleRepoStats in AppContext. The stat card labeled "Stale Issues Ratio" and its severity color (getStatus(staleIssuesRatio)) will therefore show numbers inconsistent with the "Stale Issues" tab built from staleRepoStats, which is confusing since both are presented under the same "stale" concept on this page.

Consider deriving the stat card from staleRepoStats instead, e.g. an aggregate ratio across all repos:

const totalStale = staleRepoStats.reduce((s, r) => s + r.staleCount, 0)
const totalOpen  = staleRepoStats.reduce((s, r) => s + r.openCount, 0)
const staleIssuesRatio = totalOpen ? (totalStale / totalOpen) * 100 : 0

Also applies to: 158-158

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/GovernancePage.jsx` around lines 75 - 76, The “Stale Issues Ratio”
card is using the dead-issues metric instead of the stale-issue metric, so
update the calculation in GovernancePage to derive from staleRepoStats rather
than allIssues/deadIssues. Use the existing staleRepoStats data from AppContext
to compute an aggregate stale ratio (for example, total staleCount over total
openCount), and keep getStatus(staleIssuesRatio) pointing at that corrected
value so the card matches the “Stale Issues” tab.


// Health check 3 — Zombie PRs (>90 days open)
const zombiePRs = allIssues
.filter(i => i.pull_request && daysSince(i.created_at) >= 90)
.sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at))

// Health check 3 — Risky repos (top-2 contributor concentration >80%)
const riskyRepos = model.allRepos.filter(r => {
const c = r.contributors || []
if (!c.length) return false
const total = c.reduce((s, x) => s + x.contributions, 0)
if (!total) return false
const topTwo = (c[0]?.contributions || 0) + (c[1]?.contributions || 0)
return topTwo / total > 0.8
})

// Health check 4 — No license
const noLicense = model.allRepos.filter(r => !r.license && !r.archived && !r.fork)

// Issue resolution rate per repo
const topRepos = model.allRepos.slice(0, 8)

const counts = { dead: deadIssues.length, zombie: zombiePRs.length, risky: riskyRepos.length, license: noLicense.length }
const counts = { dead: deadIssues.length, zombie: zombiePRs.length, license: noLicense.length, stale: staleIssuesRatio.toFixed(2) }

// Stat card
const StatBox = ({ label, value, sub, color }) => (
Expand Down Expand Up @@ -124,8 +155,8 @@ export default function GovernancePage() {
{/* Summary stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 24 }}>
<StatBox label="Dead Issues" value={counts.dead} sub="OPEN 90+ DAYS" color="var(--red)" />
<StatBox label="Stale Issues Ratio" value={`${staleIssuesRatio.toFixed(2)}%`} sub={`of ${allIssues.length} total issues`} color={` ${getStatus(staleIssuesRatio).color}`} />
<StatBox label="Zombie PRs" value={counts.zombie} sub="PENDING 90+ DAYS" color="var(--amber)" />
<StatBox label="Risky Repos" value={counts.risky} sub="TOP-2 CONCENTRATION" color="var(--amber)" />
<StatBox label="No License" value={counts.license} sub="COMPLIANCE MISSING" color="var(--text2)" />
</div>

Expand Down Expand Up @@ -182,7 +213,7 @@ export default function GovernancePage() {
}}
>
{t.label}{' '}
<span style={{ color: counts[t.key] > 0 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
<span style={{ color: counts[t.key] > 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
</span>
Comment on lines +216 to 218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Badge threshold > 40 is misapplied to raw-count tabs.

This condition now drives the badge color for dead, zombie, and license tabs too, but those counts values are raw counts (e.g. deadIssues.length, which per the PR screenshots can be in the thousands), not percentages. A threshold of 40 makes sense for the new percentage-based stale count but silently changes when dead/zombie/license badges turn red (previously any count > 0).

🐛 Suggested fix
-              <span style={{ color: counts[t.key] > 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
+              <span style={{ color: (t.key === 'stale' ? counts[t.key] > 40 : counts[t.key] > 0) ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span style={{ color: counts[t.key] > 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
</span>
<span style={{ color: (t.key === 'stale' ? counts[t.key] > 40 : counts[t.key] > 0) ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
</span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/GovernancePage.jsx` around lines 216 - 218, The badge color logic
in GovernancePage is using a single `counts[t.key] > 40` check for all tabs, but
only the new percentage-based stale count should use that threshold. Update the
span styling logic around the counts render so `stale` keeps the `> 40` red
cutoff, while raw-count tabs like `dead`, `zombie`, and `license` preserve their
prior behavior of turning red whenever the count is greater than 0.

</button>
Expand Down Expand Up @@ -213,28 +244,108 @@ export default function GovernancePage() {
) : <EmptyOk msg="No zombie PRs found" sub="This org reviews and closes contributions actively." />
)}

{/* Risky Repos */}
{tab === 'risky' && (
riskyRepos.length ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{riskyRepos.map(r => {
const c = r.contributors || []
const total = c.reduce((s, x) => s + x.contributions, 0) || 1
const pct = Math.round(((c[0]?.contributions || 0) + (c[1]?.contributions || 0)) / total * 100)
return (
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 14px', background: 'var(--surface2)', borderRadius: 6 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{r.name}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
Top 2 contributors own {pct}% of all commits — concentration risk
{/* Stale Issues */}
{tab === 'stale' && (
staleRepoStats.length ? (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{paginatedStaleRepos.map(repo => {
const status = getStatus(repo.ratio)

return (
<div
key={repo.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 14px',
background: 'var(--surface2)',
borderRadius: 6
}}
>
<div>
<div style={{ fontWeight: 600 }}>
{repo.repo}
</div>

<div
style={{
marginTop: 4,
fontSize: 12,
color: 'var(--text2)'
}}
>
{repo.staleCount} stale issues out of{' '}
{repo.openCount} open issues ({repo.ratio}%)
</div>
</div>

<div
style={{
display: 'flex',
alignItems: 'center',
gap: 30
}}
>
<span style={C.pill(status.color, status.bg)}>
{status.label}
</span>
<a href={`https://github.com/${repo.org}/${repo.repo}/issues`} target="_blank" rel="noreferrer"
style={{ fontSize: 12, color: 'var(--text2)', display: 'flex', alignItems: 'center', gap: 4 }}>
<FiExternalLink size={12} /> GitHub
</a>
</div>
</div>
<span style={C.pill('var(--red)', 'rgba(239,68,68,.12)')}>HIGH RISK</span>
</div>
)
})}
</div>
) : <EmptyOk msg="No high-concentration repos found" sub="Healthy contributor distribution across the portfolio." />
)
})}
</div>

{totalPages > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16
}}
>
<button
onClick={() => setStalePage(p => p - 1)}
disabled={stalePage === 1}
style={{
padding: '8px 14px',
cursor: stalePage === 1 ? 'not-allowed' : 'pointer',
opacity: stalePage === 1 ? 0.5 : 1
}}
>
← Previous
</button>

<span style={{ fontSize: 13, color: 'var(--text2)' }}>
Page {stalePage} of {totalPages}
</span>

<button
onClick={() => setStalePage(p => p + 1)}
disabled={stalePage === totalPages}
style={{
padding: '8px 14px',
cursor: stalePage === totalPages ? 'not-allowed' : 'pointer',
opacity: stalePage === totalPages ? 0.5 : 1
}}
>
Next →
</button>
Comment on lines +313 to +339

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add explicit type="button" to pagination controls.

Without an explicit type, these buttons default to type="submit" and could trigger accidental form submission if this markup is ever nested in a <form>.

🔧 Suggested fix
   <button
+    type="button"
     onClick={() => setStalePage(p => p - 1)}
     disabled={stalePage === 1}
   <button
+    type="button"
     onClick={() => setStalePage(p => p + 1)}
     disabled={stalePage === totalPages}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={() => setStalePage(p => p - 1)}
disabled={stalePage === 1}
style={{
padding: '8px 14px',
cursor: stalePage === 1 ? 'not-allowed' : 'pointer',
opacity: stalePage === 1 ? 0.5 : 1
}}
>
Previous
</button>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
Page {stalePage} of {totalPages}
</span>
<button
onClick={() => setStalePage(p => p + 1)}
disabled={stalePage === totalPages}
style={{
padding: '8px 14px',
cursor: stalePage === totalPages ? 'not-allowed' : 'pointer',
opacity: stalePage === totalPages ? 0.5 : 1
}}
>
Next
</button>
<button
type="button"
onClick={() => setStalePage(p => p - 1)}
disabled={stalePage === 1}
style={{
padding: '8px 14px',
cursor: stalePage === 1 ? 'not-allowed' : 'pointer',
opacity: stalePage === 1 ? 0.5 : 1
}}
>
Previous
</button>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
Page {stalePage} of {totalPages}
</span>
<button
type="button"
onClick={() => setStalePage(p => p + 1)}
disabled={stalePage === totalPages}
style={{
padding: '8px 14px',
cursor: stalePage === totalPages ? 'not-allowed' : 'pointer',
opacity: stalePage === totalPages ? 0.5 : 1
}}
>
Next
</button>
🧰 Tools
🪛 React Doctor (0.5.8)

[warning] 313-313: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)


[warning] 329-329: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/GovernancePage.jsx` around lines 313 - 339, Add explicit
type="button" to the pagination controls in GovernancePage so they do not
default to submit behavior. Update the two button elements that use setStalePage
for “← Previous” and “Next →” to include the button type attribute, keeping
their current disabled logic and styling unchanged.

Source: Linters/SAST tools

</div>
)}
</>
) : (
<EmptyOk
msg="No stale issues found"
sub="All repositories have active open issues."
/>
)
)}

{/* No License */}
Expand Down
Loading