diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index d8e03b8..0683a1c 100644 --- a/src/pages/ContributorProfilePage.jsx +++ b/src/pages/ContributorProfilePage.jsx @@ -68,7 +68,16 @@ async function fetchAllPages(initialUrl, headers, signal) { throw new Error('RATE_LIMIT') } if (!res.ok) { - throw new Error(`HTTP_${res.status}`) + let message = `HTTP_${res.status}` + try { + const errJson = await res.json() + if (errJson?.errors?.[0]?.message) { + message = errJson.errors[0].message + } else if (errJson?.message) { + message = errJson.message + } + } catch {} + throw new Error(message) } const data = await res.json() items = items.concat(data.items || []) @@ -105,6 +114,8 @@ const getOrgFromRepoUrl = (url) => { return match ? match[1] : '' } +const GITHUB_LOGIN_REGEX = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i + export default function ContributorProfilePage() { const { username } = useParams() const navigate = useNavigate() @@ -112,6 +123,7 @@ export default function ContributorProfilePage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [warning, setWarning] = useState('') const [rawContributions, setRawContributions] = useState([]) const [mergedPRKeys, setMergedPRKeys] = useState(new Set()) const [tab, setTab] = useState('prs') @@ -152,8 +164,20 @@ export default function ContributorProfilePage() { console.error('Failed to parse oe_recent from localStorage:', e) } } - return list - }, [orgs]) + // Also include contributor's known orgs from the analytical model if available + if (contributor?.orgs?.length) { + for (const org of contributor.orgs) { + if (typeof org === 'string' && org.trim()) { + list.push(org.trim()) + } + } + } + const normalized = list + .map(o => (typeof o === 'string' ? o.trim() : '')) + .filter(Boolean) + .filter(o => o !== 'undefined' && o !== 'null') + return Array.from(new Set(normalized)) + }, [orgs, contributor]) // Fetch contributor issues & PRs from GitHub Search API (Supports pagination & cleanup) useEffect(() => { @@ -162,7 +186,9 @@ export default function ContributorProfilePage() { setMergedPRKeys(new Set()) setSelectedOrg('all') - if (!username) { + const cleanUser = typeof username === 'string' ? username.trim() : '' + if (!cleanUser || cleanUser === 'undefined' || cleanUser === 'null' || !GITHUB_LOGIN_REGEX.test(cleanUser)) { + setError('Invalid contributor username.') setLoading(false) return } @@ -173,39 +199,116 @@ export default function ContributorProfilePage() { return } + const validOrgs = searchOrgs.filter(org => GITHUB_LOGIN_REGEX.test(org)) + if (!validOrgs.length) { + setError('No valid organizations found to query.') + setLoading(false) + return + } + let active = true const controller = new AbortController() async function fetchData() { setLoading(true) setError('') + setWarning('') try { - const encodedUser = encodeURIComponent(username) - const orgQuery = searchOrgs.map(org => `org:${encodeURIComponent(org)}`).join('+') - const url = `https://api.github.com/search/issues?q=author:${encodedUser}+${orgQuery}&per_page=100` - const mergedUrl = `https://api.github.com/search/issues?q=author:${encodedUser}+is:pr+is:merged+${orgQuery}&per_page=100` - + const encodedUser = encodeURIComponent(cleanUser) const headers = { Accept: 'application/vnd.github.v3+json' } if (pat) { headers.Authorization = `token ${pat}` } - const [items, mergedItems] = await Promise.all([ - fetchAllPages(url, headers, controller.signal), - fetchAllPages(mergedUrl, headers, controller.signal) - ]) + // Query each organization individually to avoid invalid multi-org queries + // (in GitHub search syntax, multiple org: qualifiers are treated as AND, + // which returns 0 results or 422 Unprocessable Content if an org is inaccessible) + const executeOrgQuery = async (org) => { + const encodedOrg = encodeURIComponent(org) + const url = `https://api.github.com/search/issues?q=author:${encodedUser}+org:${encodedOrg}&per_page=100` + const mergedUrl = `https://api.github.com/search/issues?q=author:${encodedUser}+is:pr+is:merged+org:${encodedOrg}&per_page=100` + + const [itemsRes, mergedRes] = await Promise.allSettled([ + fetchAllPages(url, headers, controller.signal), + fetchAllPages(mergedUrl, headers, controller.signal), + ]) + + if (itemsRes.status === 'rejected') { + throw itemsRes.reason + } + + const items = itemsRes.value || [] + const mergedItems = mergedRes.status === 'fulfilled' ? (mergedRes.value || []) : [] + const mergedFailed = mergedRes.status === 'rejected' + + return { org, items, mergedItems, mergedFailed } + } + + // Limit concurrent search requests to 2 to respect GitHub rate limits + const settled = [] + for (let i = 0; i < validOrgs.length; i += 2) { + const batch = validOrgs.slice(i, i + 2) + const batchResults = await Promise.allSettled(batch.map(org => executeOrgQuery(org))) + settled.push(...batchResults) + } if (!active) return - const mergedKeys = new Set( - mergedItems.map(item => { - const repo = getFullRepoFromUrl(item.repository_url) - return `${repo}/${item.number}` - }) - ) + const allItems = [] + const mergedKeys = new Set() + let anySuccess = false + const failureReasons = [] + const partialFailures = [] + + settled.forEach((res, idx) => { + if (res.status === 'fulfilled') { + anySuccess = true + const { org, items, mergedItems, mergedFailed } = res.value + allItems.push(...items) + mergedItems.forEach(item => { + const repo = getFullRepoFromUrl(item.repository_url) + mergedKeys.add(`${repo}/${item.number}`) + }) + if (mergedFailed) { + partialFailures.push(`${org} (merged PR status incomplete)`) + } + } else { + const err = res.reason + if (err?.name !== 'AbortError') { + const orgName = validOrgs[idx] + failureReasons.push(`${orgName}: ${err.message}`) + const failureDetail = err?.message === 'RATE_LIMIT' + ? `${orgName}: rate limit reached (configure PAT in Settings or wait)` + : (err?.message ? `${orgName}: ${err.message}` : orgName) + partialFailures.push(failureDetail) + } + } + }) + + if (!anySuccess && failureReasons.length) { + const firstRateLimit = failureReasons.some(msg => msg.includes('RATE_LIMIT')) + if (firstRateLimit) { + throw new Error('RATE_LIMIT') + } + throw new Error(failureReasons.join(', ')) + } + + if (anySuccess && partialFailures.length) { + setWarning(`Partial results loaded. Some organization queries could not be completed: ${partialFailures.join(', ')}`) + } else { + setWarning('') + } + + // Deduplicate items across org queries + const seenIds = new Set() + const dedupedItems = allItems.filter(item => { + if (!item?.id || seenIds.has(item.id)) return false + seenIds.add(item.id) + return true + }) setMergedPRKeys(mergedKeys) - setRawContributions(items) + setRawContributions(dedupedItems) } catch (err) { if (!active) return if (err.name === 'AbortError') return @@ -497,6 +600,13 @@ export default function ContributorProfilePage() { )} + {warning && ( +
+ + {warning} +
+ )} + {/* Date Filters Card */}
diff --git a/src/pages/ContributorProfilePage.test.jsx b/src/pages/ContributorProfilePage.test.jsx new file mode 100644 index 0000000..d25e31f --- /dev/null +++ b/src/pages/ContributorProfilePage.test.jsx @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import ContributorProfilePage from './ContributorProfilePage' + +const app = vi.hoisted(() => ({ + state: { + orgs: [{ login: 'AOSSIE-Org' }], + pat: '', + pullsData: {}, + model: { + contributors: [{ login: 'testuser', orgs: ['AOSSIE-Org'] }], + }, + }, +})) + +vi.mock('../context/AppContext', () => ({ useApp: () => app.state })) + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }) =>
{children}
, + BarChart: ({ children }) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, +})) + +function renderPage(username = 'testuser') { + return render( + + + } /> + + + ) +} + +describe('ContributorProfilePage', () => { + beforeEach(() => { + vi.restoreAllMocks() + app.state = { + orgs: [{ login: 'AOSSIE-Org' }], + pat: '', + pullsData: {}, + model: { + contributors: [{ login: 'testuser', orgs: ['AOSSIE-Org'] }], + }, + } + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('shows error immediately for invalid username without making network calls', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + renderPage('undefined') + + expect(await screen.findByText('Invalid contributor username.')).toBeInTheDocument() + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('queries organizations individually to avoid multi-org search 422 errors', async () => { + app.state.orgs = [{ login: 'OrgA' }, { login: 'OrgB' }] + app.state.model.contributors = [{ login: 'testuser', orgs: ['OrgA', 'OrgB'] }] + + const fetchUrls = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + fetchUrls.push(url.toString()) + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ items: [] }), + } + }) + + renderPage('testuser') + + await waitFor(() => { + expect(fetchUrls.length).toBe(4) // Exactly 2 calls per org (issues + merged PRs) + }) + + const orgAIssues = fetchUrls.filter(u => u.includes('author:testuser+org:OrgA') && !u.includes('is:merged')) + const orgAMerged = fetchUrls.filter(u => u.includes('author:testuser+is:pr+is:merged+org:OrgA')) + const orgBIssues = fetchUrls.filter(u => u.includes('author:testuser+org:OrgB') && !u.includes('is:merged')) + const orgBMerged = fetchUrls.filter(u => u.includes('author:testuser+is:pr+is:merged+org:OrgB')) + + expect(orgAIssues).toHaveLength(1) + expect(orgAMerged).toHaveLength(1) + expect(orgBIssues).toHaveLength(1) + expect(orgBMerged).toHaveLength(1) + expect(fetchUrls.some(u => u.includes('org:OrgA+org:OrgB'))).toBe(false) + }) + + it('surfaces specific error message from GitHub API response when 422 occurs', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + return { + ok: false, + status: 422, + headers: new Headers(), + json: async () => ({ + message: 'Validation Failed', + errors: [{ message: 'The listed users and repositories cannot be searched.' }], + }), + } + }) + + renderPage('testuser') + + expect( + await screen.findByText(/The listed users and repositories cannot be searched/i) + ).toBeInTheDocument() + }) + + it('retains authored contributions when merged-PR query fails', async () => { + app.state.orgs = [{ login: 'OrgA' }] + app.state.model.contributors = [{ login: 'testuser', orgs: ['OrgA'] }] + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const urlStr = url.toString() + if (urlStr.includes('is:merged')) { + return { + ok: false, + status: 500, + headers: new Headers(), + json: async () => ({ message: 'Server error on merged query' }), + } + } + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ + items: [{ + id: 101, + number: 1, + title: 'Sample Issue', + state: 'open', + pull_request: {}, + created_at: new Date().toISOString(), + repository_url: 'https://api.github.com/repos/OrgA/repo1', + html_url: 'https://github.com/OrgA/repo1/pull/1', + }], + }), + } + }) + + renderPage('testuser') + + expect(await screen.findByText(/Sample Issue/i)).toBeInTheDocument() + expect(await screen.findByText(/merged PR status incomplete/i)).toBeInTheDocument() + }) + + it('renders partial failure warning when one organization fails and another succeeds', async () => { + app.state.orgs = [{ login: 'OrgA' }, { login: 'OrgB' }] + app.state.model.contributors = [{ login: 'testuser', orgs: ['OrgA', 'OrgB'] }] + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const urlStr = url.toString() + if (urlStr.includes('org:OrgB')) { + return { + ok: false, + status: 404, + headers: new Headers(), + json: async () => ({ message: 'OrgB not found' }), + } + } + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ + items: [{ + id: 202, + number: 2, + title: 'OrgA PR', + state: 'open', + pull_request: {}, + created_at: new Date().toISOString(), + repository_url: 'https://api.github.com/repos/OrgA/repo1', + html_url: 'https://github.com/OrgA/repo1/pull/2', + }], + }), + } + }) + + renderPage('testuser') + + expect(await screen.findByText(/OrgA PR/i)).toBeInTheDocument() + expect(await screen.findByText(/Partial results loaded.*OrgB/i)).toBeInTheDocument() + }) + + it('renders rate limit guidance in partial failure warning when one organization is rate limited', async () => { + app.state.orgs = [{ login: 'OrgA' }, { login: 'OrgB' }] + app.state.model.contributors = [{ login: 'testuser', orgs: ['OrgA', 'OrgB'] }] + + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + const urlStr = url.toString() + if (urlStr.includes('org:OrgB')) { + return { + ok: false, + status: 403, + headers: new Headers({ 'x-ratelimit-remaining': '0' }), + json: async () => ({ message: 'API rate limit exceeded' }), + } + } + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ + items: [{ + id: 202, + number: 2, + title: 'OrgA PR', + state: 'open', + pull_request: {}, + created_at: new Date().toISOString(), + repository_url: 'https://api.github.com/repos/OrgA/repo1', + html_url: 'https://github.com/OrgA/repo1/pull/2', + }], + }), + } + }) + + renderPage('testuser') + + expect(await screen.findByText(/OrgA PR/i)).toBeInTheDocument() + expect(await screen.findByText(/OrgB: rate limit reached/i)).toBeInTheDocument() + }) +}) diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index f2aa028..12da2b8 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -40,6 +40,17 @@ describe('SettingsPage', () => { expect(await screen.findByRole('button', { name: /cleared/i })).toBeInTheDocument() }) + it('does not clear cache or analysis when confirmation is declined', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + + render() + + await userEvent.click(screen.getByRole('button', { name: /clear all/i })) + + expect(cacheClear).not.toHaveBeenCalled() + expect(clearAnalysis).not.toHaveBeenCalled() + }) + it('does not report success when the analysis cache fails to clear', async () => { cacheClear.mockResolvedValue(true) clearAnalysis.mockResolvedValue(false) diff --git a/src/services/analytics.buildAnalyticalModel.test.js b/src/services/analytics.buildAnalyticalModel.test.js index 85cb58a..ea12d18 100644 --- a/src/services/analytics.buildAnalyticalModel.test.js +++ b/src/services/analytics.buildAnalyticalModel.test.js @@ -162,4 +162,27 @@ describe('buildAnalyticalModel', () => { expect(result.totalRepos).toEqual([]) expect(result.contributors).toEqual([]) }) + + it('filters out anonymous contributors or entries without a login', () => { + const orgs = [{ login: 'org-a' }] + const repoA = makeRepo('repo-a') + const reposPerOrg = { 'org-a': [repoA] } + const totalReposPerOrg = { 'org-a': [repoA] } + const contribsPerRepo = { + 'org-a/repo-a': [ + { login: 'valid-user', avatar_url: '', contributions: 10 }, + { name: 'Anonymous', contributions: 5 }, + null, + ], + } + + const result = buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalReposPerOrg) + + expect(result.contributors).toHaveLength(1) + expect(result.contributors[0].login).toBe('valid-user') + expect(result.contributors.some(c => c.login === undefined)).toBe(false) + expect(result.totalRepos[0].contributors).toHaveLength(1) + expect(result.totalRepos[0].contributors[0].login).toBe('valid-user') + expect(result.totalRepos[0].busFactor.factor).toBe(1) + }) }) diff --git a/src/services/analytics.js b/src/services/analytics.js index 8546455..9c91018 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -20,19 +20,20 @@ export function computeActivityClassification(repo) { // Bus Factor export function computeBusFactor(contributors = []) { - if (!contributors.length) return { factor: 0, risk: 'unknown' } - const getCount = c => c.contributions ?? c.totalContribs ?? 0 - const total = contributors.reduce((s, c) => s + getCount(c), 0) + const valid = (contributors || []).filter(c => c && typeof c === 'object') + if (!valid.length) return { factor: 0, risk: 'unknown' } + const getCount = c => c?.contributions ?? c?.totalContribs ?? 0 + const total = valid.reduce((s, c) => s + getCount(c), 0) if (!total) return { factor: 0, risk: 'unknown' } let cum = 0 - for (let i = 0; i < contributors.length; i++) { - cum += getCount(contributors[i]) + for (let i = 0; i < valid.length; i++) { + cum += getCount(valid[i]) if (cum / total > 0.5) { const f = i + 1 return { factor: f, risk: f <= 1 ? 'critical' : f <= 2 ? 'high' : 'healthy' } } } - return { factor: contributors.length, risk: 'healthy' } + return { factor: valid.length, risk: 'healthy' } } // Unified Analytical Data Model @@ -49,7 +50,8 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalRe total.forEach(repo => { const key = `${org.login}/${repo.name}` - const contribs = contribsPerRepo[key] || [] + const rawContribs = contribsPerRepo[key] || [] + const contribs = rawContribs.filter(c => c && typeof c === 'object' && c.login) const health = computeHealthScore(repo, contribs.length) const activityClassification = computeActivityClassification(repo) const bf = computeBusFactor(contribs) @@ -63,6 +65,7 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalRe // Build contributor map — deduplicated by login across orgs contribs.forEach(c => { + if (!c || !c.login) return if (!contributorMap[c.login]) { contributorMap[c.login] = { login: c.login,