diff --git a/src/github-api.js b/src/github-api.js index e5bbeb8..af774ec 100644 --- a/src/github-api.js +++ b/src/github-api.js @@ -397,15 +397,40 @@ async function loadGitHubIssues() { // browsing sends no auth headers, preserving anonymous read-only access. const timestamp = Date.now(); const base = `/repos/${window.GitHubAuth.GITHUB_CONFIG.owner}/${window.GitHubAuth.GITHUB_CONFIG.repo}/issues`; - const openReq = await window.GitHubAuth.buildGitHubRequest(`${base}?state=open&_t=${timestamp}`); - const closedReq = await window.GitHubAuth.buildGitHubRequest(`${base}?state=closed&_t=${timestamp}`); + const openPath = `${base}?state=open&_t=${timestamp}`; + const closedPath = `${base}?state=closed&_t=${timestamp}`; const doFetch = window.RateLimit?.rateLimitedFetch || fetch; - const [openResponse, closedResponse] = await Promise.all([ - doFetch(openReq.url, { headers: openReq.headers }), - doFetch(closedReq.url, { headers: closedReq.headers }) - ]); - + // Issue lists are public, so they can always be read anonymously. When + // signed in we prefer the authenticated proxy (higher rate limit, private + // repos); `anonymous` forces the direct public path for the fallback below. + async function fetchIssues(anonymous) { + const [openReq, closedReq] = await Promise.all([ + window.GitHubAuth.buildGitHubRequest(openPath, {}, { anonymous }), + window.GitHubAuth.buildGitHubRequest(closedPath, {}, { anonymous }) + ]); + return Promise.all([ + doFetch(openReq.url, { headers: openReq.headers }), + doFetch(closedReq.url, { headers: closedReq.headers }) + ]); + } + + let [openResponse, closedResponse] = await fetchIssues(false); + + // If the authenticated proxy read failed with an auth error (e.g. the + // signed-in user hasn't connected GitHub, or their token can't access this + // repo), retry anonymously so the board still shows public issues instead + // of coming up empty. Skip the retry for a genuine rate limit (403 with + // remaining requests at 0), which the block below reports as such. + if ((!openResponse.ok || !closedResponse.ok) && window.GitHubAuth.isGitHubAuthed()) { + const failed = openResponse.ok ? closedResponse : openResponse; + const remaining = failed.headers && failed.headers.get('x-ratelimit-remaining'); + const isRateLimit = failed.status === 403 && remaining === '0'; + if ((failed.status === 401 || failed.status === 403) && !isRateLimit) { + [openResponse, closedResponse] = await fetchIssues(true); + } + } + if (!openResponse.ok || !closedResponse.ok) { const failed = openResponse.ok ? closedResponse : openResponse; const remaining = failed.headers && failed.headers.get('x-ratelimit-remaining'); @@ -419,9 +444,9 @@ async function loadGitHubIssues() { throw new Error(`GitHub API rate limit exceeded`); } - // Auth/token failure — e.g. the Clerk proxy has no GitHub token for this - // user (401/403). This is NOT a rate limit; surface an actionable message - // instead of failing silently or mislabeling it. + // Auth/token failure that even the anonymous fallback couldn't recover + // from (e.g. a private repo). This is NOT a rate limit; surface an + // actionable message instead of failing silently or mislabeling it. if (failed.status === 401 || failed.status === 403) { notifyError('Could not load GitHub issues — your GitHub access needs to be reconnected. Sign in again and make sure your account has access to this repository.'); throw new Error(`GitHub authorization failed: ${failed.status}`); diff --git a/src/github-auth.js b/src/github-auth.js index bd8036a..e2ffbe0 100644 --- a/src/github-auth.js +++ b/src/github-auth.js @@ -51,10 +51,16 @@ function isGitHubAuthed() { // origin) with a short-lived Clerk session token — the proxy swaps in the user's // real GitHub token. Anonymous calls go straight to GitHub for public, read-only // access with no auth headers. -async function buildGitHubRequest(path, extraHeaders = {}) { +// +// Pass `{ anonymous: true }` to force the direct, unauthenticated GitHub path +// regardless of sign-in state. This is used to fall back to public read-only +// access when an authenticated proxy read fails (e.g. the signed-in user's +// GitHub access can't be used by the proxy), so the board still shows public +// issues instead of failing outright. +async function buildGitHubRequest(path, extraHeaders = {}, { anonymous = false } = {}) { const headers = { ...extraHeaders }; - if (isGitHubAuthed()) { + if (!anonymous && isGitHubAuthed()) { const token = window.ClerkAuth ? await window.ClerkAuth.getToken() : null; if (token) { headers['Authorization'] = `Bearer ${token}`; diff --git a/tests/github-api.test.js b/tests/github-api.test.js index b9bb0a8..d75f749 100644 --- a/tests/github-api.test.js +++ b/tests/github-api.test.js @@ -1658,42 +1658,62 @@ describe('GitHub API', () => { expect(mockAlert).not.toHaveBeenCalled(); }); - test('loadGitHubIssues surfaces an auth error (not a rate limit) on a 403 with no rate-limit header', async () => { + test('loadGitHubIssues falls back to anonymous public read on a proxy 403 (no rate-limit header)', async () => { // The Clerk proxy returns 403 "No GitHub account connected" with no - // x-ratelimit-* headers. This must read as an auth problem, not a limit. + // x-ratelimit-* headers — an auth problem, not a rate limit. Because + // issue lists are public, retry anonymously so the board still loads + // rather than treating it as a hard failure. window.RateLimit = { handleApiResponse: jest.fn() }; mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 403, - headers: { get: () => null } - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [] - }); + .mockResolvedValueOnce({ ok: false, status: 403, headers: { get: () => null } }) + .mockResolvedValueOnce({ ok: false, status: 403, headers: { get: () => null } }); + // Subsequent (anonymous) calls fall through to the default mock, which + // returns an empty issue list for the repo. + window.GitHubUI = { + createGitHubIssueElement: jest.fn(() => document.createElement('div')), + applyReviewIndicatorsToColumn: jest.fn(), + applyCompletedSectionsToColumn: jest.fn() + }; window.GitHubAuth.githubAuth.isAuthenticated = true; window.GitHubAuth.githubAuth.mode = 'clerk'; await window.GitHubAPI.loadGitHubIssues(); expect(window.RateLimit.handleApiResponse).not.toHaveBeenCalled(); - expect(mockAlert).toHaveBeenCalledWith(expect.stringContaining('reconnected')); + expect(mockAlert).not.toHaveBeenCalled(); + // The retry went straight to GitHub's public API, not the proxy. + const retryUrls = mockFetch.mock.calls.slice(2).map((c) => c[0]); + expect(retryUrls.length).toBeGreaterThan(0); + expect(retryUrls.every((u) => u.startsWith('https://api.github.com/'))).toBe(true); }); - test('loadGitHubIssues surfaces an auth error on a 401 from the proxy', async () => { + test('loadGitHubIssues falls back to anonymous public read on a proxy 401', async () => { mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 401, - headers: { get: () => null } - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => [] - }); + .mockResolvedValueOnce({ ok: false, status: 401, headers: { get: () => null } }) + .mockResolvedValueOnce({ ok: false, status: 401, headers: { get: () => null } }); + + window.GitHubUI = { + createGitHubIssueElement: jest.fn(() => document.createElement('div')), + applyReviewIndicatorsToColumn: jest.fn(), + applyCompletedSectionsToColumn: jest.fn() + }; + window.GitHubAuth.githubAuth.isAuthenticated = true; + window.GitHubAuth.githubAuth.mode = 'clerk'; + + await window.GitHubAPI.loadGitHubIssues(); + + expect(mockAlert).not.toHaveBeenCalled(); + const retryUrls = mockFetch.mock.calls.slice(2).map((c) => c[0]); + expect(retryUrls.every((u) => u.startsWith('https://api.github.com/'))).toBe(true); + }); + + test('loadGitHubIssues surfaces an auth error when even the anonymous fallback fails', async () => { + // Both the authenticated proxy read and the anonymous retry fail with + // an auth/permission error (e.g. a private repo). Surface an actionable + // message rather than mislabeling it or failing silently. + mockFetch.mockResolvedValue({ ok: false, status: 401, headers: { get: () => null } }); window.GitHubAuth.githubAuth.isAuthenticated = true; window.GitHubAuth.githubAuth.mode = 'clerk'; diff --git a/tests/github-auth.test.js b/tests/github-auth.test.js index 2b09a44..856fb7d 100644 --- a/tests/github-auth.test.js +++ b/tests/github-auth.test.js @@ -152,6 +152,19 @@ describe('GitHub Authentication (Clerk-only)', () => { expect(url).toBe('https://api.github.com/repos/o/r/issues'); expect(headers).toEqual({}); }); + + test('forces the direct public path when { anonymous: true }, even signed in', async () => { + signInClerk(); + window.ClerkAuth = { getToken: jest.fn().mockResolvedValue('clerk-jwt') }; + + const { url, headers } = await window.GitHubAuth.buildGitHubRequest( + '/repos/o/r/issues', {}, { anonymous: true } + ); + + expect(url).toBe('https://api.github.com/repos/o/r/issues'); + expect(headers).toEqual({}); + expect(window.ClerkAuth.getToken).not.toHaveBeenCalled(); + }); }); describe('githubFetch', () => {