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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- **Fully Browser-Based** — Runs entirely in the browser using GitHub APIs with no backend server required.

- **Organization Overview Dashboard** — Explore repositories, contributors, activity trends, tech stack distribution, and organization growth insights.
- **Smart Organization Search with Autocomplete** — GitHub org suggestions with real-time dropdown search

- **Advanced Repository Analytics** — Analyze repository activity, contributor density, issue and PR trends, health metrics, and lifecycle status.

Expand Down
307 changes: 185 additions & 122 deletions src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -1,142 +1,205 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FiSearch, FiX } from 'react-icons/fi'
import { useApp } from '../context/AppContext'
import { C, Spinner } from '../components/UI'
import React, { useState,useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { FiSearch, FiX } from 'react-icons/fi'
import { useApp } from '../context/AppContext'
import { C, Spinner } from '../components/UI'
import { searchOrgs } from '../services/github'

const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus']
const QUICK = ['AOSSIE-Org', 'DjedAlliance', 'StabilityNexus']

export default function HomePage() {
const { explore, loading, loadMsg, error } = useApp()
const navigate = useNavigate()
const [input, setInput] = useState('')
const [chips, setChips] = useState([])
export default function HomePage() {
const { explore, loading, loadMsg, error } = useApp()
const navigate = useNavigate()
const [input, setInput] = useState('')
const [chips, setChips] = useState([])
const[suggestions, setSuggestions] = useState([])
const[showSuggestion, setShowSuggestions] = useState(false)

const recent = JSON.parse(localStorage.getItem('oe_recent') || '[]')
useEffect(() => {
if (!input.trim()) {
setSuggestions([])
return
}
const t = setTimeout(async () => {
const result = await searchOrgs(input.trim())
setSuggestions(result.slice(0,6))
setShowSuggestions(true)
}, 300)
return () => clearTimeout(t)
}, [input])
Comment on lines +18 to +29

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 | 🟠 Major | ⚡ Quick win

Add race condition protection and error handling.

The debounced search has two issues:

  1. Race condition: Multiple async searchOrgs calls can complete out of order. If a user types "face" → "faceb" → "faceboo" quickly, all three API calls execute, and whichever completes last wins—potentially showing stale results for "face" after the user has typed "faceboo".

  2. Silent failures: If searchOrgs fails (rate limit, network error), the user sees no feedback.

🔒 Proposed fix with cancellation and error handling
  useEffect(() => {
    if (!input.trim()) {
      setSuggestion([])
+     setShowSuggestions(false)
      return
    }
+   let cancelled = false
    const t = setTimeout(async () =>  {
-     const result = await searchOrgs(input.trim())
-     setSuggestion(result.slice(0,6))
-     setShowSuggestions(true)
+     try {
+       const result = await searchOrgs(input.trim())
+       if (!cancelled) {
+         setSuggestion(result.slice(0,6))
+         setShowSuggestions(result.length > 0)
+       }
+     } catch (err) {
+       if (!cancelled) {
+         setSuggestion([])
+         setShowSuggestions(false)
+       }
+     }
    }, 300)
-   return () => clearTimeout(t)
+   return () => {
+     clearTimeout(t)
+     cancelled = true
+   }
  }, [input])

The cancelled flag ensures stale results from earlier searches don't overwrite newer ones.

🤖 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/HomePage.jsx` around lines 18 - 29, The useEffect hook in
HomePage.jsx has a race condition where multiple searchOrgs calls can complete
out of order, showing stale results, and lacks error handling if the search
fails. Add a cancelled flag within the useEffect to track whether the current
debounced request is still valid, then before updating state with setSuggestion
and setShowSuggestions, check that the flag is false to prevent stale results
from overwriting newer ones. Additionally, wrap the searchOrgs call in a
try-catch block to handle potential errors (network failures, rate limits), and
when an error occurs, either clear suggestions or set an error state to provide
user feedback instead of silently failing. Make sure to set the cancelled flag
to true in the cleanup function before clearing the timeout to ensure cancelled
requests don't update state.


const addChip = raw => {
const parts = raw.split(/[,+\s]+/).map(s => s.trim()).filter(Boolean)
setChips(prev => [...new Set([...prev, ...parts])])
setInput('')
}
const selectSuggestion = (login) => {
setChips(prev => [...new Set([...prev,login])])
setInput('')
setSuggestion([])
setShowSuggestions(false)
}

const removeChip = c => setChips(prev => prev.filter(x => x !== c))
const recent = JSON.parse(localStorage.getItem('oe_recent') || '[]')

const handleKey = e => {
if ((e.key === 'Enter' || e.key === ',') && input.trim()) {
e.preventDefault()
addChip(input)
}
if (e.key === 'Backspace' && !input && chips.length) {
setChips(prev => prev.slice(0, -1))
const addChip = raw => {
const parts = raw.split(/[,+\s]+/).map(s => s.trim()).filter(Boolean)
setChips(prev => [...new Set([...prev, ...parts])])
setInput('')
}
}

const go = async (targets) => {
const orgs = targets || (chips.length ? chips : input.trim() ? [input.trim()] : [])
if (!orgs.length) return
const success = await explore(orgs)
if(success) navigate('/overview')
}
const removeChip = c => setChips(prev => prev.filter(x => x !== c))

return (
<div style={{
minHeight: '90vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '40px 24px', gap: 32,
}}>
{/* Hero */}
<div style={{ textAlign: 'center', maxWidth: 580 }}>
<h1 style={{ fontSize: 'clamp(30px,6vw,58px)', fontWeight: 800, lineHeight: 1.1, marginBottom: 14 }}>
Architect Your{' '}
<span style={{ color: 'var(--accent)' }}>Insights</span>
</h1>
<p style={{ fontSize: 15, color: 'var(--text2)', lineHeight: 1.7 }}>
Unified analytics across one or many GitHub organizations. Multi-org portfolio analysis, contributor network graphs, time-series trends, and governance audits — entirely in the browser.
</p>
</div>
const handleKey = e => {
if ((e.key === 'Enter' || e.key === ',') && input.trim()) {
e.preventDefault()
addChip(input)
}
if (e.key === 'Backspace' && !input && chips.length) {
setChips(prev => prev.slice(0, -1))
}
}

{/* Search */}
<div style={{ width: '100%', maxWidth: 680 }}>
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, padding: '6px 8px',
display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', minHeight: 54,
}}>
<FiSearch size={16} color="var(--text2)" style={{ marginLeft: 4, flexShrink: 0 }} />
{chips.map(c => (
<span key={c} style={{
background: 'rgba(245,197,24,.1)', border: '1px solid rgba(245,197,24,.3)',
color: 'var(--accent)', borderRadius: 4, padding: '3px 8px',
fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4,
}}>
{c}
<FiX size={12} style={{ cursor: 'pointer', opacity: .7 }} onClick={() => removeChip(c)} />
</span>
))}
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => input.trim() && addChip(input)}
placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'}
style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
/>
<button onClick={() => go()} style={{ ...C.btn('primary'), padding: '8px 22px', flexShrink: 0 }}>
EXPLORE
</button>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6, paddingLeft: 4 }}>
Type an org name and press Enter or comma to add. Add multiple orgs to analyze as a unified portfolio.
</p>
{error && <p style={{ color: 'var(--red)', fontSize: 12, marginTop: 8 }}>{error}</p>}
</div>
const go = async (targets) => {
const orgs = targets || (chips.length ? chips : input.trim() ? [input.trim()] : [])
if (!orgs.length) return
const success = await explore(orgs)
if(success) navigate('/overview')
}

{/* Loading */}
{loading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<Spinner />
<p style={{ color: 'var(--text2)', fontSize: 13 }}>{loadMsg}</p>
return (
<div style={{
minHeight: '90vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '40px 24px', gap: 32,
}}>
{/* Hero */}
<div style={{ textAlign: 'center', maxWidth: 580 }}>
<h1 style={{ fontSize: 'clamp(30px,6vw,58px)', fontWeight: 800, lineHeight: 1.1, marginBottom: 14 }}>
Architect Your{' '}
<span style={{ color: 'var(--accent)' }}>Insights</span>
</h1>
<p style={{ fontSize: 15, color: 'var(--text2)', lineHeight: 1.7 }}>
Unified analytics across one or many GitHub organizations. Multi-org portfolio analysis, contributor network graphs, time-series trends, and governance audits — entirely in the browser.
</p>
</div>
)}

{/* Recent */}
{recent.length > 0 && !loading && (
<div style={{ textAlign: 'center' }}>
<span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Recent searches</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
{recent.map(r => (
<button key={r} onClick={() => go(r.split(',').map(s => s.trim()))} style={{ ...C.btn('ghost'), fontSize: 12, padding: '4px 12px' }}>
{r}
</button>

{/* Search */}
<div style={{ width: '100%', maxWidth: 680, position: 'relative'}}>
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 10, padding: '6px 8px',
display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', minHeight: 54,
}}>
<FiSearch size={16} color="var(--text2)" style={{ marginLeft: 4, flexShrink: 0 }} />
{chips.map(c => (
<span key={c} style={{
background: 'rgba(245,197,24,.1)', border: '1px solid rgba(245,197,24,.3)',
color: 'var(--accent)', borderRadius: 4, padding: '3px 8px',
fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4,
}}>
{c}
<FiX size={12} style={{ cursor: 'pointer', opacity: .7 }} onClick={() => removeChip(c)} />
</span>
))}
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => !showSuggestions && input.trim() && addChip(input)}
aria-label="Search GitHub organizations"
placeholder={chips.length ? 'Add another org...' : 'AOSSIE-Org, StabilityNexus, DjedAlliance...'}
style={{ flex: 1, minWidth: 160, background: 'none', color: 'var(--text)', fontSize: 14, padding: '4px 8px', border: 'none', outline: 'none' }}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button type = "button" onClick={() => go()} style={{ ...C.btn('primary'), padding: '8px 22px', flexShrink: 0 }}>
EXPLORE
</button>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6, paddingLeft: 4 }}>
Type an org name and press Enter or comma to add. Add multiple orgs to analyze as a unified portfolio.
</p>
{error && <p style={{ color: 'var(--red)', fontSize: 12, marginTop: 8 }}>{error}</p>}
{showSuggestion && suggestions.length > 0 && (
<div style={{
position: 'relative',
width: '100%',
}}>
<div style={{
position: 'absolute',
top: 6,
left: 0,
right: 0,
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
zIndex: 20,
maxHeight: 220,
overflowY: 'auto'
}}>
{suggestions.map(org => (
<div
key={org.login}
onMouseDown={() => selectSuggestion(org.login)}
style={{
padding: '10px 12px',
cursor: 'pointer',
fontSize: 13,
display: 'flex',
justifyContent: 'space-between'
}}
>
<span>{org.login}</span>
<span style={{ color: 'var(--text3)', fontSize: 11 }}>
org
</span>
</div>
))}
</div>
</div>
)}
Comment on lines +117 to +154

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 | 🟠 Major | 🏗️ Heavy lift

Enhance accessibility and keyboard navigation.

The autocomplete dropdown has several gaps:

  1. Semantic HTML: Clickable <div> elements should use role="button" or be actual <button> elements for screen reader users.
  2. Keyboard navigation: Users cannot use arrow keys to navigate suggestions or Escape to close the dropdown.
  3. Hover feedback: No visual indication when hovering over suggestions.
  4. Internationalization: The hardcoded "org" label violates the i18n requirement in path instructions.
♻️ Suggested improvements

1. Add semantic role and hover styles:

  {suggestions.map(org => (
    <div
      key={org.login}
+     role="button"
+     tabIndex={0}
      onMouseDown={() => selectSuggestion(org.login)}
+     onKeyDown={(e) => e.key === 'Enter' && selectSuggestion(org.login)}
      style={{
        padding: '10px 12px',
        cursor: 'pointer',
        fontSize: 13,
        display: 'flex',
        justifyContent: 'space-between'
      }}
+     onMouseEnter={(e) => e.currentTarget.style.background = 'var(--hover)'}
+     onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
    >

2. Add keyboard navigation in useEffect:

Add handling for ArrowDown, ArrowUp, and Escape keys to navigate through suggestions.

3. Externalize "org" string:

As per path instructions, user-visible strings should be externalized to resource files for internationalization.

As per path instructions, user-visible strings should be externalized and the code should adhere to React best practices and accessibility standards.

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 145-145: A list component should have a key to prevent re-rendering
Context: {org.login}
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)


[warning] 146-148: A list component should have a key to prevent re-rendering
Context: <span style={{ color: 'var(--text3)', fontSize: 11 }}>
org

Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)

🪛 React Doctor (0.5.8)

[warning] 135-135: Screen reader users can't tell this click handler is interactive because it has no role, so add a role or use a button or link.

Give clickable static elements a role, or use a button or link.

(no-static-element-interactions)

🤖 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/HomePage.jsx` around lines 117 - 154, The autocomplete dropdown
suggestions lack accessibility and keyboard navigation support. First, add
role="button" to the suggestion items (the divs with onMouseDown handlers) and
include hover styles for visual feedback. Second, implement keyboard navigation
by adding a useEffect hook that listens for ArrowDown, ArrowUp, and Escape key
events to navigate through suggestions and close the dropdown. Third,
externalize the hardcoded "org" string label used in the suggestions list to an
i18n resource file instead of keeping it as a literal string in the JSX,
following the path instructions for internationalization compliance.

Sources: Path instructions, Linters/SAST tools


</div>
)}

{/* Quick explore */}
{!loading && (
<div style={{ textAlign: 'center' }}>
<span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Quick explore</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
{QUICK.map(q => (
<button key={q} onClick={() => go([q])} style={{ ...C.btn('secondary'), fontSize: 12, padding: '5px 14px' }}>
{q}
</button>
))}
{/* Loading */}
{loading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<Spinner />
<p style={{ color: 'var(--text2)', fontSize: 13 }}>{loadMsg}</p>
</div>
</div>
)}
)}

{/* Recent */}
{recent.length > 0 && !loading && (
<div style={{ textAlign: 'center' }}>
<span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Recent searches</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
{recent.map(r => (
<button key={r} onClick={() => go(r.split(',').map(s => s.trim()))} style={{ ...C.btn('ghost'), fontSize: 12, padding: '4px 12px' }}>
{r}
</button>
))}
</div>
</div>
)}

{/* Stats bar */}
<div style={{ display: 'flex', gap: 48, padding: '20px 40px', background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}>
{[['5,000', 'req/hr with PAT', 'var(--green)'], ['1HR', 'intelligent cache', 'var(--green)'], ['ZERO', 'backend latency', 'var(--accent)']].map(([v, l, color]) => (
<div key={l} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color }}>{v}</div>
<div style={{ fontSize: 10, color: 'var(--text2)', letterSpacing: '.07em', textTransform: 'uppercase', marginTop: 2 }}>{l}</div>
{/* Quick explore */}
{!loading && (
<div style={{ textAlign: 'center' }}>
<span style={{ ...C.label, display: 'block', marginBottom: 8 }}>Quick explore</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
{QUICK.map(q => (
<button key={q} onClick={() => go([q])} style={{ ...C.btn('secondary'), fontSize: 12, padding: '5px 14px' }}>
{q}
</button>
))}
</div>
</div>
))}
)}

{/* Stats bar */}
<div style={{ display: 'flex', gap: 48, padding: '20px 40px', background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' }}>
{[['5,000', 'req/hr with PAT', 'var(--green)'], ['1HR', 'intelligent cache', 'var(--green)'], ['ZERO', 'backend latency', 'var(--accent)']].map(([v, l, color]) => (
<div key={l} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color }}>{v}</div>
<div style={{ fontSize: 10, color: 'var(--text2)', letterSpacing: '.07em', textTransform: 'uppercase', marginTop: 2 }}>{l}</div>
</div>
))}
</div>
</div>
</div>
)
}
)
}
13 changes: 13 additions & 0 deletions src/services/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ export async function fetchRepos(org, pat) {
return all
}

export const searchOrgs = async (query) => {
if (!query) return []

const res = await fetch(
`https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org`
)
if (!res.ok) return []

const data = await res.json()
return data.items || []
}
Comment on lines +88 to +98

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 | 🟠 Major | 🏗️ Heavy lift

Refactor to use the existing fetchWithCache infrastructure.

searchOrgs bypasses the established API helper pattern used by fetchOrg and fetchRepos. This causes several issues:

  1. No PAT authentication — Search is limited to 60 requests/hour instead of 5,000/hour with a token.
  2. No caching — Repeated autocomplete queries (e.g., typing "face", "faceb", "faceboo") make redundant API calls.
  3. No rate limit handling — Unlike fetchWithCache (line 64), this won't throw 'RATE_LIMIT' errors for downstream handling.
  4. Inconsistent error handling — Returns [] instead of throwing, unlike other helpers.
♻️ Proposed refactor to reuse fetchWithCache
-export const searchOrgs = async (query) => {
-  if (!query) return []
-
-  const res = await fetch(
-    `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org`
-  )
-  if (!res.ok) return []
-  
-  const data = await res.json()
-  return data.items || []
-}
+export const searchOrgs = async (query, pat) => {
+  if (!query) return []
+  
+  try {
+    const url = `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org`
+    const data = await fetchWithCache(url, pat)
+    return data.items || []
+  } catch (err) {
+    // Return empty on errors (NOT_FOUND, RATE_LIMIT, etc.)
+    return []
+  }
+}

This will require passing pat from HomePage through to searchOrgs. You can access it via useApp() context.

As per path instructions, the code should adhere to performance best practices.

🤖 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/services/github.js` around lines 88 - 98, Refactor the searchOrgs
function to use the existing fetchWithCache infrastructure instead of making a
direct fetch call, following the same pattern as fetchOrg and fetchRepos
functions. The searchOrgs function currently bypasses authentication, caching,
and rate limit handling. To fix this, replace the direct fetch implementation
with a call to fetchWithCache, passing the GitHub search users API endpoint and
the query parameter. You will need to obtain the PAT token from the useApp()
context hook and pass it to fetchWithCache to enable proper authentication and
access the higher rate limits, along with the established caching and rate limit
error handling mechanisms.

Source: Path instructions



export async function fetchContributors(org, repo, pat) {
try {
return await fetchWithCache(
Expand Down
Loading