diff --git a/src/pages/ContributorProfilePage.jsx b/src/pages/ContributorProfilePage.jsx index 3cafec8..9fbdc00 100644 --- a/src/pages/ContributorProfilePage.jsx +++ b/src/pages/ContributorProfilePage.jsx @@ -101,6 +101,9 @@ const getFullRepoFromUrl = (url) => { export default function ContributorProfilePage() { const { username } = useParams() + const cleanUsername = useMemo(() => { + return username ? username.replace(/^@/, '') : '' + }, [username]) const navigate = useNavigate() const { orgs, pat, pullsData } = useApp() @@ -148,7 +151,8 @@ export default function ContributorProfilePage() { setRawContributions([]) setMergedPRKeys(new Set()) - if (!username) { + if (!cleanUsername) { + setError('') setLoading(false) return } @@ -166,7 +170,7 @@ export default function ContributorProfilePage() { setLoading(true) setError('') try { - const encodedUser = encodeURIComponent(username) + const encodedUser = encodeURIComponent(cleanUsername) 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` @@ -176,10 +180,28 @@ export default function ContributorProfilePage() { headers.Authorization = `token ${pat}` } - const [items, mergedItems] = await Promise.all([ - fetchAllPages(url, headers, controller.signal), - fetchAllPages(mergedUrl, headers, controller.signal) - ]) + let items, mergedItems + try { + const [resItems, resMergedItems] = await Promise.all([ + fetchAllPages(url, headers, controller.signal), + fetchAllPages(mergedUrl, headers, controller.signal) + ]) + items = resItems + mergedItems = resMergedItems + } catch (fetchErr) { + if (pat && fetchErr.message.includes('HTTP_422')) { + console.warn("Authenticated search failed with 422 (likely due to fine-grained PAT scopes restriction). Retrying with public unauthenticated request...") + const publicHeaders = { Accept: 'application/vnd.github.v3+json' } + const [publicItems, publicMergedItems] = await Promise.all([ + fetchAllPages(url, publicHeaders, controller.signal), + fetchAllPages(mergedUrl, publicHeaders, controller.signal) + ]) + items = publicItems + mergedItems = publicMergedItems + } else { + throw fetchErr + } + } if (!active) return @@ -213,7 +235,7 @@ export default function ContributorProfilePage() { active = false controller.abort() } - }, [username, searchOrgs, pat]) + }, [cleanUsername, searchOrgs, pat]) // Presets using local date offsets const setPreset = (type) => { @@ -333,7 +355,7 @@ export default function ContributorProfilePage() { const orgsStr = searchOrgs.join(', ') const dateRangeStr = (startDate || 'Beginning') + ' to ' + (endDate || 'Present') - let md = `# Contribution Report: ${username}\n\n` + let md = `# Contribution Report: ${cleanUsername}\n\n` md += `* **Generated on:** ${dateStr}\n` md += `* **Organizations explored:** ${orgsStr}\n` md += `* **Reporting Period:** ${dateRangeStr}\n\n` @@ -381,7 +403,7 @@ export default function ContributorProfilePage() { const url = URL.createObjectURL(blob) const a = Object.assign(document.createElement('a'), { href: url, - download: `contribution-report-${username}-${new Date().toISOString().slice(0, 10)}.md` + download: `contribution-report-${cleanUsername}-${new Date().toISOString().slice(0, 10)}.md` }) document.body.appendChild(a) a.click() @@ -419,7 +441,7 @@ export default function ContributorProfilePage() { {error && ( -
- - {error} +
+
+ + {error} +
+
+
Debug Info (for error diagnostic):
+
• sanitized username: "{cleanUsername}" (raw: "{username}")
+
• searchOrgs: {JSON.stringify(searchOrgs)}
+
• PAT token: {pat ? 'Present' : 'Not set'}
+
)} diff --git a/src/pages/ContributorProfilePage.test.jsx b/src/pages/ContributorProfilePage.test.jsx new file mode 100644 index 0000000..dc80a71 --- /dev/null +++ b/src/pages/ContributorProfilePage.test.jsx @@ -0,0 +1,108 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' +import ContributorProfilePage from './ContributorProfilePage' + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ username: 'rohan-pandeyy' }), + useNavigate: () => vi.fn() +})) + +const mockUseApp = { + orgs: [{ login: 'AOSSIE-Org' }], + pat: 'mock-pat', + pullsData: {}, + isComplete: true, + loading: false, + runFullExplore: vi.fn() +} + +vi.mock('../context/AppContext', () => ({ + useApp: () => mockUseApp +})) + +describe('ContributorProfilePage fetch retry logic', () => { + let fetchMock + + beforeEach(() => { + fetchMock = vi.spyOn(global, 'fetch') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('retries with public unauthenticated headers when authenticated search fails with HTTP 422', async () => { + const todayIso = new Date().toISOString().slice(0, 10) + // Mock sequence: + // 1 & 2: Authenticated fetches fail with 422 + // 3 & 4: Unauthenticated retries succeed + fetchMock + .mockResolvedValueOnce({ + status: 422, + ok: false, + headers: new Headers() + }) + .mockResolvedValueOnce({ + status: 422, + ok: false, + headers: new Headers() + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ items: [{ id: 1, title: 'Mock PR', number: 42, created_at: todayIso, html_url: 'http://url', pull_request: {} }] }) + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ items: [] }) + }) + + render() + + // Wait for the mock issues title to appear + await waitFor(() => { + expect(screen.getByText('Mock PR')).toBeInTheDocument() + }) + + // Verify fetch was called 4 times in total (2 authenticated + 2 public retries) + expect(fetchMock).toHaveBeenCalledTimes(4) + + // Verify first fetch calls included the Authorization header + const firstCallHeaders = fetchMock.mock.calls[0][1].headers + expect(firstCallHeaders.Authorization).toBe('token mock-pat') + + // Verify fallback fetch calls did NOT include the Authorization header + const fallbackCallHeaders = fetchMock.mock.calls[2][1].headers + expect(fallbackCallHeaders.Authorization).toBeUndefined() + }) + + it('does not retry and propagates error for non-422 failures', async () => { + // Mock authenticated fetch failing with 500 Internal Server Error + fetchMock.mockResolvedValue({ + status: 500, + ok: false, + headers: new Headers() + }) + + render() + + // Wait for error card to render + await waitFor(() => { + expect(screen.getByText('Failed to fetch contributor details: HTTP_500')).toBeInTheDocument() + }) + + // Verify fetch was called only twice (1 for main search, 1 for merged search running in parallel) + // and no unauthenticated retries were executed + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +})