diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 770bee7..759c6c0 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,10 +1,15 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github' +import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls, bumpPatGeneration, asValidRateLimit } from '../services/github' import { buildAnalyticalModel, getTopRepositories } from '../services/analytics' import { saveAnalysis, loadAnalysis } from '../services/cache' const Ctx = createContext(null) +function isStaleRateLimitIncrease(current, next) { + if (!current || next.limit !== current.limit || next.remaining <= current.remaining) return false + return current.reset > 0 && Date.now() < current.reset * 1000 +} + function getStoredRateLimit() { const stored = localStorage.getItem('oe_rate_limit') @@ -32,6 +37,8 @@ export function AppProvider({ children }) { const [issuesData, setIssuesData] = useState({}) const [pullsData, setPullsData] = useState({}) const [rateLimit, setRateLimit] = useState(getStoredRateLimit) + const rateLimitRef = useRef(rateLimit) + const patGenerationRef = useRef(0) const [loading, setLoading] = useState(false) const [loadMsg, setLoadMsg] = useState('') const [govLoading, setGovLoading] = useState(false) @@ -100,8 +107,12 @@ export function AppProvider({ children }) { useEffect(() => { const handler = e => { - setRateLimit(e.detail) - localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail)) + const normalized = asValidRateLimit(e.detail) + if (!normalized) return + if (isStaleRateLimitIncrease(rateLimitRef.current, normalized)) return + rateLimitRef.current = normalized + setRateLimit(normalized) + localStorage.setItem('oe_rate_limit', JSON.stringify(normalized)) } window.addEventListener('rate-limit-update', handler) @@ -115,6 +126,7 @@ export function AppProvider({ children }) { if (!rateLimit?.reset) return const timeout = setTimeout(() => { + rateLimitRef.current = null localStorage.removeItem('oe_rate_limit') setRateLimit(null) }, Math.max(0, rateLimit.reset * 1000 - Date.now())) @@ -123,16 +135,29 @@ export function AppProvider({ children }) { }, [rateLimit]) const refreshRateLimit = useCallback(async () => { + const generation = patGenerationRef.current const rl = await fetchRateLimit(pat) - if (rl) { - setRateLimit(rl) - return true - } - return false + if (!rl) return false + if (generation !== patGenerationRef.current) return 'superseded' + if ((pat || '') !== (localStorage.getItem('oe_pat') || '')) return 'superseded' + // `/rate_limit` can lag behind live response headers. Ignore stale + // increases while the current window is still active. + if (isStaleRateLimitIncrease(rateLimitRef.current, rl)) return true + rateLimitRef.current = rl + setRateLimit(rl) + localStorage.setItem('oe_rate_limit', JSON.stringify(rl)) + return true }, [pat]) const savePat = useCallback(token => { + patGenerationRef.current += 1 + bumpPatGeneration() setPat(token) token ? localStorage.setItem('oe_pat', token) : localStorage.removeItem('oe_pat') + // Old quota belongs to the old PAT. Clear it so the new PAT's higher + // remaining count is accepted instead of being blocked as stale. + rateLimitRef.current = null + localStorage.removeItem('oe_rate_limit') + setRateLimit(null) }, []) // Multi-org explore diff --git a/src/context/AppContext.test.jsx b/src/context/AppContext.test.jsx new file mode 100644 index 0000000..2f53e66 --- /dev/null +++ b/src/context/AppContext.test.jsx @@ -0,0 +1,148 @@ +import { act, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppProvider, useApp } from './AppContext' + +const { loadAnalysis, saveAnalysis, fetchRateLimit, bumpPatGeneration } = vi.hoisted(() => ({ + loadAnalysis: vi.fn(), + saveAnalysis: vi.fn(), + fetchRateLimit: vi.fn(), + bumpPatGeneration: vi.fn(), +})) + +vi.mock('../services/cache', () => ({ loadAnalysis, saveAnalysis })) +vi.mock('../services/github', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + fetchOrg: vi.fn(), + fetchRepos: vi.fn(), + fetchContributors: vi.fn(), + fetchIssues: vi.fn(), + fetchPulls: vi.fn(), + fetchRateLimit, + bumpPatGeneration, + } +}) + +function RateLimitProbe() { + const { rateLimit, savePat, refreshRateLimit } = useApp() + const [refreshResult, setRefreshResult] = useState('') + return ( + <> + {JSON.stringify(rateLimit)} + {refreshResult} + + + + ) +} + +function dispatchRateLimit(detail) { + act(() => { + window.dispatchEvent(new CustomEvent('rate-limit-update', { detail })) + }) +} + +describe('AppContext rate-limit events', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-10-05T12:00:00Z')) + localStorage.clear() + loadAnalysis.mockResolvedValue(null) + saveAnalysis.mockReset() + fetchRateLimit.mockReset() + bumpPatGeneration.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + localStorage.clear() + }) + + it('rejects same-window increases but accepts decreases and next-window updates', () => { + const reset = Math.floor(Date.now() / 1000) + 60 + const current = { limit: 5000, remaining: 4200, used: 800, reset } + localStorage.setItem('oe_rate_limit', JSON.stringify(current)) + + render( + + + + ) + + dispatchRateLimit({ ...current, remaining: 5000, used: 0 }) + expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current)) + expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current) + + const decreased = { ...current, remaining: 4199, used: 801 } + dispatchRateLimit(decreased) + expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(decreased)) + + vi.setSystemTime(new Date((reset + 1) * 1000)) + const nextWindow = { ...current, remaining: 5000, used: 0, reset: reset + 3600 } + dispatchRateLimit(nextWindow) + expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(nextWindow)) + }) + + it('ignores invalid rate-limit events', () => { + const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 } + localStorage.setItem('oe_rate_limit', JSON.stringify(current)) + + render( + + + + ) + + dispatchRateLimit({ ...current, limit: 'invalid' }) + + expect(screen.getByTestId('rate-limit').textContent).toBe(JSON.stringify(current)) + expect(JSON.parse(localStorage.getItem('oe_rate_limit'))).toEqual(current) + }) + + it('clears the displayed and stored quota when a PAT is saved', () => { + const current = { limit: 5000, remaining: 4200, used: 800, reset: 1_800_000_000 } + localStorage.setItem('oe_pat', 'old-token') + localStorage.setItem('oe_rate_limit', JSON.stringify(current)) + + render( + + + + ) + + act(() => screen.getByRole('button', { name: 'Save PAT' }).click()) + + expect(screen.getByTestId('rate-limit').textContent).toBe('null') + expect(localStorage.getItem('oe_pat')).toBe('new-token') + expect(localStorage.getItem('oe_rate_limit')).toBeNull() + expect(bumpPatGeneration).toHaveBeenCalledOnce() + }) + + it('marks a refresh as superseded when the stored PAT changes mid-request', async () => { + let resolveRefresh + localStorage.setItem('oe_pat', 'old-token') + fetchRateLimit.mockImplementation( + () => new Promise(resolve => { resolveRefresh = resolve }) + ) + + render( + + + + ) + + act(() => screen.getByRole('button', { name: 'Refresh quota' }).click()) + localStorage.setItem('oe_pat', 'new-token') + await act(async () => { + resolveRefresh({ limit: 5000, remaining: 4000, used: 1000, reset: 1_800_000_000 }) + await Promise.resolve() + }) + + expect(screen.getByTestId('refresh-result').textContent).toBe('superseded') + expect(localStorage.getItem('oe_rate_limit')).toBeNull() + }) +}) diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 85ae0ae..c19c0a4 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -268,8 +268,9 @@ export default function SettingsPage() { setIsRefreshing(true); setRefreshError(false); try { - const success = await refreshRateLimit(); - if (!success) { + const result = await refreshRateLimit(); + // 'superseded' means a PAT change discarded this result — not a failure. + if (result === false) { setRefreshError(true); setTimeout(() => setRefreshError(false), 2000); } diff --git a/src/services/github.cacheKey.test.js b/src/services/github.cacheKey.test.js index 6e0b942..0f69ae0 100644 --- a/src/services/github.cacheKey.test.js +++ b/src/services/github.cacheKey.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { normalizeCacheKey, fetchOrg, fetchRepos, fetchContributors } from './github' +import { cacheGet, normalizeCacheKey, fetchOrg, fetchRepos, fetchContributors } from './github' /** Minimal in-memory stand-in for the IndexedDB API surface used by github.js. */ function installFakeIndexedDB() { @@ -30,6 +30,12 @@ function installFakeIndexedDB() { queueMicrotask(() => req.onsuccess && req.onsuccess()) return req }, + delete(k) { + table.delete(k) + const req = {} + queueMicrotask(() => req.onsuccess && req.onsuccess()) + return req + }, clear() { table.clear() const req = {} @@ -53,6 +59,7 @@ function installFakeIndexedDB() { return req }, }) + return tables } /** Counting fetch stub. Handler maps a URL to { status, body }. */ @@ -111,6 +118,14 @@ describe('normalizeCacheKey', () => { ) }) + it('preserves case in path segments beyond owner and repository names', () => { + expect( + normalizeCacheKey('https://api.github.com/repos/AOSSIE-Org/Repo-A/contents/ReadMe.md') + ).toBe( + 'https://api.github.com/repos/aossie-org/repo-a/contents/ReadMe.md' + ) + }) + it('sorts query parameters deterministically', () => { expect( normalizeCacheKey('https://api.github.com/orgs/aossie-org/repos?page=1&per_page=100') @@ -133,6 +148,21 @@ describe('normalizeCacheKey', () => { }) describe('case-insensitive caching', () => { + it('migrates an existing raw URL cache entry to its normalized key', async () => { + const tables = installFakeIndexedDB() + const oldKey = 'https://api.github.com/orgs/AOSSIE-Org' + const normalizedKey = normalizeCacheKey(oldKey) + const record = { k: oldKey, v: { login: 'AOSSIE-Org' }, ts: Date.now() } + tables.set('cache', new Map([[oldKey, record]])) + + await expect(cacheGet(oldKey)).resolves.toEqual(record.v) + expect(tables.get('cache').get(normalizedKey)).toEqual({ + ...record, + k: normalizedKey, + }) + expect(tables.get('cache').has(oldKey)).toBe(false) + }) + it('re-searching an org with different case costs zero extra fetches', async () => { installFakeIndexedDB() const calls = installCountingFetch({ login: 'AOSSIE-Org', public_repos: 1 }) @@ -167,22 +197,38 @@ describe('case-insensitive caching', () => { }) describe('empty-repository responses', () => { - for (const status of [204, 409]) { - it(`caches contributors ${status} (empty repo) so repeats cost nothing`, async () => { - installFakeIndexedDB() - const calls = installFetchStub(() => ({ status, body: null })) - - const first = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat') - expect(first).toEqual([]) - expect(calls).toHaveLength(1) - - // Previously this threw inside res.json() on every call, was never - // cached, and burned one token per re-search. - const second = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat') - expect(second).toEqual([]) - expect(calls).toHaveLength(1) - }) - } + it('does not treat an empty object response as an empty list', async () => { + installFakeIndexedDB() + const calls = installFetchStub(() => ({ status: 204, body: null })) + + await expect(fetchOrg('AOSSIE-Org', 'pat')).rejects.toThrow('HTTP_204') + await expect(fetchOrg('AOSSIE-Org', 'pat')).rejects.toThrow('HTTP_204') + expect(calls).toHaveLength(2) + }) + + it('caches contributors 204 (empty repo) so repeats cost nothing', async () => { + installFakeIndexedDB() + const calls = installFetchStub(() => ({ status: 204, body: null })) + + const first = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat') + expect(first).toEqual([]) + expect(calls).toHaveLength(1) + + // Previously this threw inside res.json() on every call, was never + // cached, and burned one token per re-search. + const second = await fetchContributors('AOSSIE-Org', 'empty-repo', 'pat') + expect(second).toEqual([]) + expect(calls).toHaveLength(1) + }) + + it('rejects 409 responses instead of caching them as empty results', async () => { + installFakeIndexedDB() + const calls = installFetchStub(() => ({ status: 409, body: null })) + + await expect(fetchContributors('AOSSIE-Org', 'repo-a', 'pat')).rejects.toThrow('HTTP_409') + await expect(fetchContributors('AOSSIE-Org', 'repo-a', 'pat')).rejects.toThrow('HTTP_409') + expect(calls).toHaveLength(2) + }) it('still throws RATE_LIMIT (403) and NOT_FOUND (404)', async () => { installFakeIndexedDB() diff --git a/src/services/github.js b/src/services/github.js index 2cc733b..3f4d5a0 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -3,6 +3,13 @@ const DB_NAME = 'orgexplorer_cache' const STORE = 'cache' const TTL_MS = 3_600_000 // 1 hour +// PAT generation: bumped every time the saved PAT changes (even A -> B -> A). +// Requests capture it at start and drop their quota event if it changed, +// so a late response from an old identity never overwrites current state. +let patGeneration = 0 +export function bumpPatGeneration() { patGeneration += 1 } +export function getPatGeneration() { return patGeneration } + /** * Canonicalize an API URL into a deterministic cache key. * @@ -19,7 +26,15 @@ const TTL_MS = 3_600_000 // 1 hour export function normalizeCacheKey(url) { try { const u = new URL(url) - u.pathname = (u.pathname.replace(/\/+$/, '') || '/').toLowerCase() + const path = u.pathname.replace(/\/+$/, '') || '/' + const segments = path.split('/') + if (segments[1] === 'orgs' && segments[2]) { + segments[2] = segments[2].toLowerCase() + } else if (segments[1] === 'repos' && segments[2] && segments[3]) { + segments[2] = segments[2].toLowerCase() + segments[3] = segments[3].toLowerCase() + } + u.pathname = segments.join('/') u.searchParams.sort() return u.toString() } catch { @@ -39,12 +54,29 @@ function openDB() { export async function cacheGet(key) { try { const db = await openDB() - return new Promise(res => { - const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(normalizeCacheKey(key)) + return new Promise((res, rej) => { + const normalizedKey = normalizeCacheKey(key) + const store = db.transaction(STORE, 'readonly').objectStore(STORE) + const req = store.get(normalizedKey) req.onsuccess = () => { const r = req.result - if (!r || Date.now() - r.ts > TTL_MS) return res(null) - res(r.v) + if (r) return res(Date.now() - r.ts > TTL_MS ? null : r.v) + if (key === normalizedKey) return res(null) + + const legacyReq = store.get(key) + legacyReq.onsuccess = () => { + const legacy = legacyReq.result + if (!legacy || Date.now() - legacy.ts > TTL_MS) return res(null) + const migrationTx = db.transaction(STORE, 'readwrite') + const migrationStore = migrationTx.objectStore(STORE) + migrationStore + .put({ ...legacy, k: normalizedKey }) + migrationStore.delete(key) + migrationTx.oncomplete = () => res(legacy.v) + migrationTx.onerror = () => rej(migrationTx.error || new Error('Failed to migrate cache entry')) + migrationTx.onabort = () => rej(migrationTx.error || new Error('Failed to migrate cache entry')) + } + legacyReq.onerror = () => res(null) } req.onerror = () => res(null) }) @@ -76,7 +108,8 @@ export async function cacheClear() { } // Core fetchWithCache -async function fetchWithCache(url, pat) { +async function fetchWithCache(url, pat, emptyResult) { + const generationAtStart = patGeneration // L2 check const cached = await cacheGet(url) if (cached) return cached @@ -86,30 +119,39 @@ async function fetchWithCache(url, pat) { const res = await fetch(url, { headers }) - window.dispatchEvent( - new CustomEvent('rate-limit-update', { - detail: { - limit: Number(res.headers.get('x-ratelimit-limit')), - remaining: Number(res.headers.get('x-ratelimit-remaining')), - used: Number(res.headers.get('x-ratelimit-used')), - reset: Number(res.headers.get('x-ratelimit-reset')) - } - }) - ) + const live = readRateLimitHeaders(res.headers) + if (live) { + // Drop quota events from a superseded PAT: if the user saved a new + // token while this request was in flight, its counters belong to the + // old identity and must not overwrite the cleared/current state. + // Generation catches even A -> B -> A (same value, new save). + let superseded = generationAtStart !== patGeneration + if (!superseded) { + try { + const currentPat = typeof localStorage !== 'undefined' ? localStorage.getItem('oe_pat') || '' : null + if (currentPat !== null) superseded = (pat || '') !== currentPat + } catch { /* keep generation-check result on storage failure */ } + } + if (!superseded) { + window.dispatchEvent( + new CustomEvent('rate-limit-update', { detail: live }) + ) + } + } if (res.status === 403) throw new Error('RATE_LIMIT') if (res.status === 404) throw new Error('NOT_FOUND') - if (res.status === 204 || res.status === 409) { + if (res.status === 204) { + if (emptyResult === undefined) throw new Error(`HTTP_${res.status}`) // Empty repository (e.g. contributors on a repo with no commits): // GitHub answers with no JSON body, so res.json() below would throw // and the failure would never be cached — every re-search would spend // another token on the same URL. Cache the empty result instead. // All list-endpoint callers treat [] as "no data", and analytics // already defaults missing entries to []. - const empty = [] - cacheSet(url, empty) // write-back, non-blocking - return empty + cacheSet(url, emptyResult) // write-back, non-blocking + return emptyResult } if (!res.ok) throw new Error(`HTTP_${res.status}`) @@ -128,7 +170,7 @@ export async function fetchRepos(org, repoCount, pat) { const maxPages = pat ? Math.ceil(repoCount / 100) : 5 for (let page = 1; page <= maxPages; page++) { const url = `https://api.github.com/orgs/${org}/repos?per_page=100&page=${page}&sort=updated` - const data = await fetchWithCache(url, pat) + const data = await fetchWithCache(url, pat, []) all.push(...data) if (data.length < 100) break } @@ -140,7 +182,7 @@ export async function fetchContributors(org, repo, pat) { const maxPages = pat ? 10 : 1 for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/contributors?per_page=100&page=${page}` - const data = await fetchWithCache(url, pat) + const data = await fetchWithCache(url, pat, []) all.push(...data) if(data.length < 100) break } @@ -152,7 +194,7 @@ export async function fetchIssues(org, repo, pat) { const maxPages = pat ? 10 : 1 for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/issues?state=all&per_page=100&page=${page}` - const data = await fetchWithCache(url, pat) + const data = await fetchWithCache(url, pat, []) all.push(...data) if(data.length < 100) break } @@ -164,19 +206,62 @@ export async function fetchPulls(org, repo, pat) { const maxPages = pat ? 10 : 1 for(let page = 1; page<=maxPages ; page++) { const url = `https://api.github.com/repos/${org}/${repo}/pulls?state=all&per_page=100&page=${page}` - const data = await fetchWithCache(url, pat) + const data = await fetchWithCache(url, pat, []) all.push(...data) if(data.length < 100) break } return all } +/** Shared numeric validation, nonnegative checks and reset fallback. */ +export function normalizeRateLimit(limitRaw, remainingRaw, usedRaw, resetRaw) { + if (limitRaw == null || remainingRaw == null) return null + const limit = Number(limitRaw) + const remaining = Number(remainingRaw) + if (!Number.isFinite(limit) || !Number.isFinite(remaining)) return null + if (limit < 0 || remaining < 0) return null + const used = Number(usedRaw) + const reset = Number(resetRaw) + return { + limit, + remaining, + used: Number.isFinite(used) ? used : limit - remaining, + reset: Number.isFinite(reset) ? reset : 0, + } +} + +/** Read live counters from response headers (authoritative per GitHub docs). + * Returns null when the headers are absent so callers can fall back. */ +function readRateLimitHeaders(h) { + if (!h || typeof h.get !== 'function') return null + const rawLimit = h.get('x-ratelimit-limit') + const rawRemaining = h.get('x-ratelimit-remaining') + if (rawLimit == null || rawRemaining == null) return null + const rawUsed = Number(h.get('x-ratelimit-used')) + const rawReset = h.get('x-ratelimit-reset') + // `x-ratelimit-used` is not in Access-Control-Expose-Headers, so browsers + // always read it as null -> 0. Derive it instead of showing a false 0. + const usedRaw = Number.isFinite(rawUsed) && rawUsed > 0 ? rawUsed : undefined + return normalizeRateLimit(rawLimit, rawRemaining, usedRaw, rawReset) +} + +/** Validate a rate-limit object from the `/rate_limit` body. */ +export function asValidRateLimit(obj) { + if (!obj) return null + return normalizeRateLimit(obj.limit, obj.remaining, obj.used, obj.reset) +} + export async function fetchRateLimit(pat) { try { const headers = { Accept: 'application/vnd.github.v3+json' } if (pat) headers.Authorization = `token ${pat}` const res = await fetch('https://api.github.com/rate_limit', { headers }) - const data = await res.json() - return data.rate + const data = await res.json().catch(() => null) + // Headers are the authoritative source; the body (`rate` is closing + // down, and the body can disagree with the live counters) is fallback. + return readRateLimitHeaders(res.headers) + ?? asValidRateLimit(data?.resources?.core) + ?? asValidRateLimit(data?.rate) + ?? null } catch { return null } }