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
56 changes: 43 additions & 13 deletions src/pages/ContributorProfilePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -148,7 +151,8 @@ export default function ContributorProfilePage() {
setRawContributions([])
setMergedPRKeys(new Set())

if (!username) {
if (!cleanUsername) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setError('')
setLoading(false)
return
}
Expand All @@ -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`
Expand All @@ -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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!active) return

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -419,7 +441,7 @@ export default function ContributorProfilePage() {
</div>

<PageTitle
title={`Contributor Profile: @${username}`}
title={`Contributor Profile: @${cleanUsername}`}
subtitle={`Analyzing contributions across ${searchOrgs.join(', ')}`}
right={
<button
Expand All @@ -433,9 +455,17 @@ export default function ContributorProfilePage() {
/>

{error && (
<div style={{ ...C.card, display: 'flex', alignItems: 'center', gap: 12, borderColor: 'var(--red)', background: 'rgba(239,68,68,.05)', marginBottom: 20 }}>
<FiAlertTriangle color="var(--red)" size={18} />
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 500 }}>{error}</span>
<div style={{ ...C.card, display: 'flex', flexDirection: 'column', gap: 12, borderColor: 'var(--red)', background: 'rgba(239,68,68,.05)', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<FiAlertTriangle color="var(--red)" size={18} />
<span style={{ fontSize: 13, color: 'var(--red)', fontWeight: 500 }}>{error}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', background: 'var(--bg)', padding: 12, borderRadius: 4, fontFamily: 'monospace', textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 4 }}>
<div><strong>Debug Info (for error diagnostic):</strong></div>
<div>• sanitized username: &quot;{cleanUsername}&quot; (raw: &quot;{username}&quot;)</div>
<div>• searchOrgs: {JSON.stringify(searchOrgs)}</div>
<div>• PAT token: {pat ? 'Present' : 'Not set'}</div>
</div>
</div>
)}

Expand Down
108 changes: 108 additions & 0 deletions src/pages/ContributorProfilePage.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<ContributorProfilePage />)

// 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(<ContributorProfilePage />)

// 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)
})
})
Loading