From 27a9579f3865299a7870d7ba6ddfb91e44011c4a Mon Sep 17 00:00:00 2001 From: Hrishikesh Yadav Date: Sat, 3 Oct 2026 21:31:07 +0530 Subject: [PATCH 1/3] fix(contributors): prevent HTTP 422 in contributor search and filter anonymous logins (#232) --- src/pages/ContributorProfilePage.jsx | 99 +++++++++++++--- src/pages/ContributorProfilePage.test.jsx | 110 ++++++++++++++++++ .../analytics.buildAnalyticalModel.test.js | 20 ++++ src/services/analytics.js | 14 ++- 4 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 src/pages/ContributorProfilePage.test.jsx diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index d8e03b85..707675e1 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 || []) @@ -152,8 +161,19 @@ export default function ContributorProfilePage() { console.error('Failed to parse oe_recent from localStorage:', e) } } + // 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.includes(org.trim())) { + list.push(org.trim()) + } + } + } return list - }, [orgs]) + .map(o => (typeof o === 'string' ? o.trim() : '')) + .filter(Boolean) + .filter(o => o !== 'undefined' && o !== 'null') + }, [orgs, contributor]) // Fetch contributor issues & PRs from GitHub Search API (Supports pagination & cleanup) useEffect(() => { @@ -162,7 +182,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') { + setError('Invalid contributor username.') setLoading(false) return } @@ -180,32 +202,71 @@ export default function ContributorProfilePage() { setLoading(true) setError('') 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 orgQueries = searchOrgs.map(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 [items, mergedItems] = await Promise.all([ + fetchAllPages(url, headers, controller.signal), + fetchAllPages(mergedUrl, headers, controller.signal), + ]) + + return { org, items, mergedItems } + }) + const settled = await Promise.allSettled(orgQueries) 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 = [] + + settled.forEach((res, idx) => { + if (res.status === 'fulfilled') { + anySuccess = true + const { items, mergedItems } = res.value + allItems.push(...items) + mergedItems.forEach(item => { + const repo = getFullRepoFromUrl(item.repository_url) + mergedKeys.add(`${repo}/${item.number}`) + }) + } else { + const err = res.reason + if (err?.name !== 'AbortError') { + failureReasons.push(`${searchOrgs[idx]}: ${err.message}`) + } + } + }) + + 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(', ')) + } + + // 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 diff --git a/src/pages/ContributorProfilePage.test.jsx b/src/pages/ContributorProfilePage.test.jsx new file mode 100644 index 00000000..c76f79d1 --- /dev/null +++ b/src/pages/ContributorProfilePage.test.jsx @@ -0,0 +1,110 @@ +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' }] + + 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).toBeGreaterThanOrEqual(4) // 2 calls per org (issues + merged PRs) + }) + + // Confirm queries are separated per organization (e.g. org:OrgA, org:OrgB) and not concatenated as org:OrgA+org:OrgB + expect(fetchUrls.some(u => u.includes('org:OrgA') && !u.includes('org:OrgB'))).toBe(true) + expect(fetchUrls.some(u => u.includes('org:OrgB') && !u.includes('org:OrgA'))).toBe(true) + 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() + }) +}) diff --git a/src/services/analytics.buildAnalyticalModel.test.js b/src/services/analytics.buildAnalyticalModel.test.js index 85cb58ae..fc864b39 100644 --- a/src/services/analytics.buildAnalyticalModel.test.js +++ b/src/services/analytics.buildAnalyticalModel.test.js @@ -162,4 +162,24 @@ 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) + }) }) diff --git a/src/services/analytics.js b/src/services/analytics.js index 8546455e..eb392961 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 @@ -63,6 +64,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, From 77a46088ff8bc549e976c678b4bfdc42e3c63adf Mon Sep 17 00:00:00 2001 From: Hrishikesh Yadav Date: Sat, 3 Oct 2026 21:56:56 +0530 Subject: [PATCH 2/3] fix(contributors): pace search queries, handle partial failures, and validate logins --- src/pages/ContributorProfilePage.jsx | 66 +++++++++++-- src/pages/ContributorProfilePage.test.jsx | 93 ++++++++++++++++++- src/pages/SettingsPage.test.jsx | 11 +++ .../analytics.buildAnalyticalModel.test.js | 3 + src/services/analytics.js | 3 +- 5 files changed, 161 insertions(+), 15 deletions(-) diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index 707675e1..0e562543 100644 --- a/src/pages/ContributorProfilePage.jsx +++ b/src/pages/ContributorProfilePage.jsx @@ -114,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() @@ -121,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') @@ -164,15 +167,16 @@ export default function ContributorProfilePage() { // 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.includes(org.trim())) { + if (typeof org === 'string' && org.trim()) { list.push(org.trim()) } } } - return list + 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) @@ -183,7 +187,7 @@ export default function ContributorProfilePage() { setSelectedOrg('all') const cleanUser = typeof username === 'string' ? username.trim() : '' - if (!cleanUser || cleanUser === 'undefined' || cleanUser === 'null') { + if (!cleanUser || cleanUser === 'undefined' || cleanUser === 'null' || !GITHUB_LOGIN_REGEX.test(cleanUser)) { setError('Invalid contributor username.') setLoading(false) return @@ -195,12 +199,20 @@ 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(cleanUser) const headers = { Accept: 'application/vnd.github.v3+json' } @@ -211,40 +223,61 @@ export default function ContributorProfilePage() { // 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 orgQueries = searchOrgs.map(async (org) => { + 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 [items, mergedItems] = await Promise.all([ + const [itemsRes, mergedRes] = await Promise.allSettled([ fetchAllPages(url, headers, controller.signal), fetchAllPages(mergedUrl, headers, controller.signal), ]) - return { org, items, mergedItems } - }) + 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) + } - const settled = await Promise.allSettled(orgQueries) if (!active) return const allItems = [] const mergedKeys = new Set() let anySuccess = false const failureReasons = [] + const partialFailures = [] settled.forEach((res, idx) => { if (res.status === 'fulfilled') { anySuccess = true - const { items, mergedItems } = res.value + 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') { - failureReasons.push(`${searchOrgs[idx]}: ${err.message}`) + const orgName = validOrgs[idx] + failureReasons.push(`${orgName}: ${err.message}`) + partialFailures.push(orgName) } } }) @@ -257,6 +290,12 @@ export default function ContributorProfilePage() { 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 => { @@ -558,6 +597,13 @@ export default function ContributorProfilePage() { )} + {warning && ( +
+ + {warning} +
+ )} + {/* Date Filters Card */}
diff --git a/src/pages/ContributorProfilePage.test.jsx b/src/pages/ContributorProfilePage.test.jsx index c76f79d1..bc8534a8 100644 --- a/src/pages/ContributorProfilePage.test.jsx +++ b/src/pages/ContributorProfilePage.test.jsx @@ -64,6 +64,7 @@ describe('ContributorProfilePage', () => { 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) => { @@ -79,12 +80,18 @@ describe('ContributorProfilePage', () => { renderPage('testuser') await waitFor(() => { - expect(fetchUrls.length).toBeGreaterThanOrEqual(4) // 2 calls per org (issues + merged PRs) + expect(fetchUrls.length).toBe(4) // Exactly 2 calls per org (issues + merged PRs) }) - // Confirm queries are separated per organization (e.g. org:OrgA, org:OrgB) and not concatenated as org:OrgA+org:OrgB - expect(fetchUrls.some(u => u.includes('org:OrgA') && !u.includes('org:OrgB'))).toBe(true) - expect(fetchUrls.some(u => u.includes('org:OrgB') && !u.includes('org:OrgA'))).toBe(true) + 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) }) @@ -107,4 +114,82 @@ describe('ContributorProfilePage', () => { 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() + }) }) diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index f2aa0288..12da2b80 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 fc864b39..ea12d186 100644 --- a/src/services/analytics.buildAnalyticalModel.test.js +++ b/src/services/analytics.buildAnalyticalModel.test.js @@ -181,5 +181,8 @@ describe('buildAnalyticalModel', () => { 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 eb392961..9c910188 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -50,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) From 88a913af5c15fb0bed882901cdbe06853a1f294f Mon Sep 17 00:00:00 2001 From: Hrishikesh Yadav Date: Mon, 5 Oct 2026 08:39:53 +0530 Subject: [PATCH 3/3] fix(contributors): include error details and rate limit guidance in partial failure warnings --- src/pages/ContributorProfilePage.jsx | 5 ++- src/pages/ContributorProfilePage.test.jsx | 39 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index 0e562543..0683a1c5 100644 --- a/src/pages/ContributorProfilePage.jsx +++ b/src/pages/ContributorProfilePage.jsx @@ -277,7 +277,10 @@ export default function ContributorProfilePage() { if (err?.name !== 'AbortError') { const orgName = validOrgs[idx] failureReasons.push(`${orgName}: ${err.message}`) - partialFailures.push(orgName) + 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) } } }) diff --git a/src/pages/ContributorProfilePage.test.jsx b/src/pages/ContributorProfilePage.test.jsx index bc8534a8..d25e31f0 100644 --- a/src/pages/ContributorProfilePage.test.jsx +++ b/src/pages/ContributorProfilePage.test.jsx @@ -192,4 +192,43 @@ describe('ContributorProfilePage', () => { 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() + }) })