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
6 changes: 3 additions & 3 deletions src/components/UI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ export const C = {
},
}

// Lifecycle badge color map
// Activity badge color map
const LC = {
Thriving: ['#22c55e', 'rgba(34,197,94,.15)'],
Stable: ['#3b82f6', 'rgba(59,130,246,.15)'],
Active: ['#3b82f6', 'rgba(59,130,246,.15)'],
Dormant: ['#f59e0b', 'rgba(245,158,11,.15)'],
Abandoned: ['#ef4444', 'rgba(239,68,68,.15)'],
Hibernating: ['#ef4444', 'rgba(239,68,68,.15)'],
critical: ['#ef4444', 'rgba(239,68,68,.15)'],
high: ['#f59e0b', 'rgba(245,158,11,.15)'],
healthy: ['#22c55e', 'rgba(34,197,94,.15)'],
Expand Down
4 changes: 2 additions & 2 deletions src/pages/OverviewPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function OverviewPage() {
const isMulti = orgs.length > 1
const totalStars = allRepos.reduce((s, r) => s + r.stargazers_count, 0)
const totalForks = allRepos.reduce((s, r) => s + r.forks_count, 0)
const activeRepos = allRepos.filter(r => r.lifecycle === 'Thriving' || r.lifecycle === 'Stable').length
const activeRepos = allRepos.filter(r => r.activityClassification === 'Thriving' || r.activityClassification === 'Active').length

const langMap = {}
allRepos.forEach(r => { if (r.language) langMap[r.language] = (langMap[r.language] || 0) + 1 })
Expand Down Expand Up @@ -183,7 +183,7 @@ export default function OverviewPage() {

{/* Nav cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 14 }}>
<NavCard to="/repositories" label="Repositories" sub="Explore and sort repos by health, activity, and lifecycle state" />
<NavCard to="/repositories" label="Repositories" sub="Explore and sort repos by health, and activity classification state" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Externalize this new nav subtitle string to i18n resources.

Line 186 introduces a new user-facing literal directly in JSX. Please move it to your translation/resource layer instead of hardcoding it inline.

Suggested change
-        <NavCard to="/repositories" label="Repositories" sub="Explore and sort repos by health, and activity classification state" />
+        <NavCard
+          to="/repositories"
+          label={t('overview.nav.repositories.label')}
+          sub={t('overview.nav.repositories.sub')}
+        />

As per coding guidelines, "User-visible strings should be externalized to resource files (i18n)".

🤖 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/OverviewPage.jsx` at line 186, The hardcoded user-visible subtitle
passed as the sub prop on the NavCard in the OverviewPage component should be
moved into the i18n resource bundle: add a new translation key (e.g.
"overview.nav.repositories.sub") to your locale files with value "Explore and
sort repos by health, and activity classification state", then replace the
inline literal in the OverviewPage JSX (the NavCard usage that sets sub="...")
with an i18n lookup (e.g. t('overview.nav.repositories.sub') or the project's
translate helper). Ensure the locale file is saved/committed and that any
required import (useTranslation/t) is added to OverviewPage.jsx so the key
resolves at runtime; adjust snapshot/tests if they assert the literal.

Source: Coding guidelines

<NavCard to="/contributors" label="Contributors" sub="Analyze contribution patterns, bus factor, and connector signals" />
<NavCard to="/network" label="Network Graph" sub="Visualize contributor-repository relationships with D3 force graph" />
<NavCard to="/analytics" label="Analytics" sub="Time-series PR and issue velocity — weekly and monthly trends" />
Expand Down
44 changes: 21 additions & 23 deletions src/pages/RepositoriesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import { exportReposCSV } from '../services/analytics'
import EmptyStateCard from '../components/EmptyStateCard'
import { useNavigate } from 'react-router-dom'

const LIFECYCLES = ['All', 'Thriving', 'Stable', 'Dormant', 'Abandoned']
const LC_ACTIVE = { Thriving: 'var(--green)', Stable: 'var(--blue)', Dormant: 'var(--amber)', Abandoned: 'var(--red)' }
const ACTIVITY_CLASSIFICATIONS = ['All', 'Thriving', 'Active', 'Dormant', 'Hibernating']
const ACTIVITY_COLORS = { Thriving: 'var(--green)', Active: 'var(--blue)', Dormant: 'var(--amber)', Hibernating: 'var(--red)' }

export default function RepositoriesPage() {
const { model } = useApp()
const [search, setSearch] = useState('')
const [lifecycle, setLifecycle] = useState('All')
const [activityClassification, setActivityClassification] = useState('All')
const [lang, setLang] = useState('All')
const [view, setView] = useState('grid')
const [shown, setShown] = useState(20)
Expand Down Expand Up @@ -45,11 +45,11 @@ export default function RepositoriesPage() {
[allRepos])

const filtered = useMemo(() => allRepos.filter(r =>
(lifecycle === 'All' || r.lifecycle === lifecycle) &&
(activityClassification === 'All' || r.activityClassification === activityClassification) &&
(lang === 'All' || r.language === lang) &&
(!search || r.name.toLowerCase().includes(search.toLowerCase()) ||
(r.description || '').toLowerCase().includes(search.toLowerCase()))
), [allRepos, lifecycle, lang, search])
), [allRepos, activityClassification, lang, search])

const { sorted, sortConfig, onSort } = useSortedData(filtered, 'healthScore', 'desc')
const visible = sorted.slice(0, shown)
Expand All @@ -60,7 +60,7 @@ export default function RepositoriesPage() {
['forks_count', 'Forks'],
['open_issues_count', 'Open Issues'],
['healthScore', 'Health'],
['lifecycle', 'Lifecycle'],
['activityClassification', 'Activity Classification'],
['pushed_at', 'Last Push'],
]

Expand All @@ -80,7 +80,7 @@ export default function RepositoriesPage() {
</button>
</div>
}
subtitle="Technical health and lifecycle across all repositories in the portfolio"
subtitle="Repository insights and activity classification across all repositories."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Centralized i18n gap: newly introduced classification copy is hardcoded across UI and exports.

The same root cause appears in multiple changed sites: new user-facing strings are embedded directly instead of being sourced from localization resources.

  • src/pages/RepositoriesPage.jsx#L83-L83: move subtitle text to i18n keys.
  • src/pages/RepositoriesPage.jsx#L120-L122: move explanatory popover text to i18n keys.
  • src/pages/RepositoriesPage.jsx#L132-L137: move classification legend labels/descriptions to i18n keys.
  • src/pages/RepositoriesPage.jsx#L156-L156: move input placeholder text to i18n keys.
  • src/services/analytics.js#L155-L156: move CSV header labels to i18n resources (or a shared localization-aware export label map).

As per coding guidelines, "User-visible strings should be externalized to resource files (i18n)."

📍 Affects 2 files
  • src/pages/RepositoriesPage.jsx#L83-L83 (this comment)
  • src/pages/RepositoriesPage.jsx#L120-L122
  • src/pages/RepositoriesPage.jsx#L132-L137
  • src/pages/RepositoriesPage.jsx#L156-L156
  • src/services/analytics.js#L155-L156
🤖 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/RepositoriesPage.jsx` at line 83, Move all hardcoded user-visible
strings into i18n resource keys and use the localization helper in the
RepositoriesPage component and analytics export: in
src/pages/RepositoriesPage.jsx (lines 83-83) replace the subtitle literal with a
call to the i18n lookup (e.g., t('repositories.subtitle')); in the same file
(lines 120-122) replace the popover explanatory text with
t('repositories.classificationPopover'); in that file (lines 132-137) replace
the classification legend labels and descriptions with
t('repositories.classification.legend.*') keys; in that file (line 156) move the
input placeholder to t('repositories.filter.placeholder'); and in
src/services/analytics.js (lines 155-156) replace hardcoded CSV header labels
with i18n-based labels (e.g., import the same i18n helper or a shared
localization-aware CSV_LABELS map) so exports use localized strings; add
corresponding keys to the i18n resource file.

Source: Coding guidelines

right={
<span style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)' }}>
{filtered.length}
Expand Down Expand Up @@ -117,8 +117,8 @@ export default function RepositoriesPage() {
</div>

<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12 }}>
OrgExplorer evaluates repositories using activity, issue health,
contributor diversity, and lifecycle status.
OrgExplorer evaluates repositories using issue health,
contributor diversity, and activity classification status.
</p>

<div style={{ fontSize: 12, lineHeight: 1.7 }}>
Expand All @@ -129,12 +129,12 @@ export default function RepositoriesPage() {
<li>Contributor Diversity → 30%</li>
</ul>

<strong>Lifecycle Classification</strong>
<strong>Activity Classification</strong>
<ul style={{ marginLeft: 18 }}>
<li>🟢 Thriving → Updated within 30 days</li>
<li>🔵 Stable → Updated within 90 days</li>
<li>🔵 Active → Updated within 90 days</li>
<li>🟡 Dormant → Updated within 180 days</li>
<li>🔴 Abandoned → No updates for 180+ days</li>
<li>🔴 Hibernating → No updates for 180+ days</li>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the Hibernating threshold wording to match the actual classifier logic.

Line 137 says “180+ days”, but the classifier marks <= 180 as Dormant and only > 180 as Hibernating. Update copy to “more than 180 days” (or “181+ days”).

🤖 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/RepositoriesPage.jsx` at line 137, The copy in RepositoriesPage.jsx
incorrectly says "180+ days" while the classifier uses "> 180" for Hibernating;
update the list item text in the RepositoriesPage component (the string "🔴
Hibernating → No updates for 180+ days") to "🔴 Hibernating → No updates for
more than 180 days" (or "181+ days") so the wording matches the classifier
logic.

</ul>

<strong>Repository Signals</strong>
Expand Down Expand Up @@ -172,14 +172,14 @@ export default function RepositoriesPage() {
</button>
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 12, flexWrap: 'wrap' }}>
{LIFECYCLES.map(l => (
{ACTIVITY_CLASSIFICATIONS.map(l => (
<button
key={l} onClick={() => { setLifecycle(l); setShown(20) }}
key={l} onClick={() => { setActivityClassification(l); setShown(20) }}
style={{
padding: '4px 12px', borderRadius: 4, fontSize: 12, fontWeight: 500, cursor: 'pointer',
border: lifecycle === l ? 'none' : '1px solid var(--border)',
background: lifecycle === l ? (LC_ACTIVE[l] || 'var(--accent)') : 'transparent',
color: lifecycle === l ? '#000' : 'var(--text2)',
border: activityClassification === l ? 'none' : '1px solid var(--border)',
background: activityClassification === l ? (ACTIVITY_COLORS[l] || 'var(--accent)') : 'transparent',
color: activityClassification === l ? '#000' : 'var(--text2)',
}}
>
{l}
Expand Down Expand Up @@ -211,7 +211,7 @@ export default function RepositoriesPage() {
<td style={{ padding: '10px 14px', fontSize: 13, color: 'var(--text2)' }}>{r.forks_count.toLocaleString()}</td>
<td style={{ padding: '10px 14px', fontSize: 13, color: r.open_issues_count > 30 ? 'var(--red)' : 'var(--text2)' }}>{r.open_issues_count}</td>
<td style={{ padding: '10px 14px', minWidth: 130 }}><HealthBar score={r.healthScore} /></td>
<td style={{ padding: '10px 14px' }}><Badge text={r.lifecycle} /></td>
<td style={{ padding: '10px 14px' }}><Badge text={r.activityClassification} /></td>
<td style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text2)' }}>{r.pushed_at?.slice(0, 10)}</td>
</tr>
))}
Expand All @@ -229,12 +229,10 @@ export default function RepositoriesPage() {
<div
key={r.id}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.borderColor =
r.lifecycle === 'Thriving' ? 'rgba(34,197,94,.25)' :
r.lifecycle === 'Abandoned' ? 'rgba(239,68,68,.25)' : 'var(--border)'}
onMouseLeave={e => e.currentTarget.style.borderColor = ACTIVITY_COLORS[r.activityClassification]}
style={{
...C.card,
borderColor: r.lifecycle === 'Thriving' ? 'rgba(34,197,94,.25)' : r.lifecycle === 'Abandoned' ? 'rgba(239,68,68,.25)' : 'var(--border)',
borderColor: ACTIVITY_COLORS[r.activityClassification],
transition: 'border-color .2s', display: 'flex', flexDirection: 'column', gap: 10,
}}
>
Expand All @@ -243,7 +241,7 @@ export default function RepositoriesPage() {
<div style={{ fontWeight: 600, fontSize: 14 }}>{r.name}</div>
{r.orgLogin && <div style={{ fontSize: 11, color: 'var(--text2)' }}>{r.orgLogin}</div>}
</div>
<Badge text={r.lifecycle} />
<Badge text={r.activityClassification} />
</div>
<p style={{ fontSize: 12, color: 'var(--text2)', minHeight: 34, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{r.description || 'No description provided'}
Expand Down
30 changes: 15 additions & 15 deletions src/services/analytics.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Repo Health Indicator (Section 3.2.6)
// Repo Health Indicator
// Activity (40%) + Issue Health (30%) + Diversity (30%)
export function computeHealthScore(repo, contributorCount = 0) {
const daysSince = (Date.now() - new Date(repo.pushed_at)) / 86_400_000
Expand All @@ -9,16 +9,16 @@ export function computeHealthScore(repo, contributorCount = 0) {
return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3)
}

// Repo Lifecycle (Section 3.2.6) — Thriving, Stable, Dormant, Abandoned based on recency of last push
export function computeLifecycle(repo) {
// Repo Lifecycle — Thriving, Active, Dormant, Hibernating based on recency of last push
export function computeActivityClassification(repo) {
const days = (Date.now() - new Date(repo.pushed_at)) / 86_400_000
if (days <= 30) return 'Thriving'
if (days <= 90) return 'Stable'
if (days <= 90) return 'Active'
if (days <= 180) return 'Dormant'
return 'Abandoned'
return 'Hibernating'
}

// Bus Factor (Section 3.2.6)
// Bus Factor
export function computeBusFactor(contributors = []) {
if (!contributors.length) return { factor: 0, risk: 'unknown' }
const total = contributors.reduce((s, c) => s + c.contributions, 0)
Expand All @@ -34,9 +34,9 @@ export function computeBusFactor(contributors = []) {
return { factor: contributors.length, risk: 'healthy' }
}

// Unified Analytical Data Model (Section 3.2.0)
// Unified Analytical Data Model
// Merges multiple orgs into one normalized graph:
// Organization → Repositories → Contributors → Issues/PRs
// Organization → Repositories → Contributors → Issues/PRs
export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo) {
const allRepos = []
const contributorMap = {}
Expand All @@ -48,9 +48,9 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo) {
const key = `${org.login}/${repo.name}`
const contribs = contribsPerRepo[key] || []
const health = computeHealthScore(repo, contribs.length)
const lc = computeLifecycle(repo)
const activityClassification = computeActivityClassification(repo)
const bf = computeBusFactor(contribs)
allRepos.push({ ...repo, orgLogin: org.login, contributors: contribs, healthScore: health, lifecycle: lc, busFactor: bf })
allRepos.push({ ...repo, orgLogin: org.login, contributors: contribs, healthScore: health, activityClassification: activityClassification, busFactor: bf })

// Build contributor map — deduplicated by login across orgs
contribs.forEach(c => {
Expand Down Expand Up @@ -86,11 +86,11 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo) {
: 0,
})).sort((a, b) => b.totalContribs - a.totalContribs)

// Graph is constructed here and persisted through cache layers (Section 3.2.0)
// Graph is constructed here and persisted through cache layers
return { allRepos, contributors }
}

// Time-Series Bucketing (Section 3.2.9)
// Time-Series Bucketing
// Parses created_at, closed_at, merged_at into weekly/monthly bins
export function buildTimeSeries(issues = [], granularity = 'monthly') {
const buckets = {}
Expand Down Expand Up @@ -142,7 +142,7 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') {
.slice(-12)
}

// CSV Export (Section 3.2.9)
// CSV Export
function download(content, filename, type = 'text/csv') {
const blob = new Blob([content], { type })
const url = URL.createObjectURL(blob)
Expand All @@ -152,8 +152,8 @@ function download(content, filename, type = 'text/csv') {
}

export function exportReposCSV(repos) {
const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Lifecycle','Language','Last Active']
const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.lifecycle, r.language || 'N/A', r.pushed_at?.slice(0, 10)])
const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Activity Classification','Language','Last Active']
const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)])
download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-repos.csv')
Comment on lines +156 to 157

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sanitize CSV cells to prevent spreadsheet formula injection.

Line 156 exports untrusted repository fields directly into CSV. Values beginning with =, +, -, or @ can execute formulas when opened in spreadsheet tools.

Suggested fix
+function escapeCsvCell(value) {
+  const s = String(value ?? '')
+  const prefixed = /^[=+\-@]/.test(s) ? `'${s}` : s
+  return `"${prefixed.replace(/"/g, '""')}"`
+}
+
 export function exportReposCSV(repos) {
   const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Activity Classification','Language','Last Active']
-  const rows   = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)])
-  download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-repos.csv')
+  const rows   = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)])
+  download(
+    [header, ...rows].map(row => row.map(escapeCsvCell).join(',')).join('\n'),
+    'orgexplorer-repos.csv'
+  )
 }
🤖 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/analytics.js` around lines 156 - 157, Sanitize CSV cells before
joining: update the rows creation (the const rows = repos.map(...) line) so each
field extracted from r (e.g., r.name, r.orgLogin, r.stargazers_count,
r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification,
r.language, r.pushed_at) is converted to a string, null/undefined normalized
(e.g., to empty string or 'N/A'), and any value that begins with =, +, -, or @
is escaped by prefixing a single quote (') or otherwise neutralizing the leading
character, then pass those sanitized strings into the existing
download([...].map(...).join('\n')) call; ensure you apply this sanitization
consistently for every cell in the rows array so no untrusted field is exported
raw.

}

Expand Down
Loading