-
-
Notifications
You must be signed in to change notification settings - Fork 44
Refactor/lifecycle to activity classification #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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'], | ||
| ] | ||
|
|
||
|
|
@@ -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." | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
As per coding guidelines, "User-visible strings should be externalized to resource files (i18n)." 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| right={ | ||
| <span style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)' }}> | ||
| {filtered.length} | ||
|
|
@@ -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 }}> | ||
|
|
@@ -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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the Hibernating threshold wording to match the actual classifier logic. Line 137 says “180+ days”, but the classifier marks 🤖 Prompt for AI Agents |
||
| </ul> | ||
|
|
||
| <strong>Repository Signals</strong> | ||
|
|
@@ -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} | ||
|
|
@@ -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> | ||
| ))} | ||
|
|
@@ -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, | ||
| }} | ||
| > | ||
|
|
@@ -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'} | ||
|
|
||
| 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 | ||
|
|
@@ -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) | ||
|
|
@@ -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 = {} | ||
|
|
@@ -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 => { | ||
|
|
@@ -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 = {} | ||
|
|
@@ -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) | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sanitize CSV cells to prevent spreadsheet formula injection. Line 156 exports untrusted repository fields directly into CSV. Values beginning with 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 |
||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
As per coding guidelines, "User-visible strings should be externalized to resource files (i18n)".
🤖 Prompt for AI Agents
Source: Coding guidelines