Skip to content
Merged
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
45 changes: 35 additions & 10 deletions src/github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
throw new Error(`GitHub API error: ${response.status} - ${errorData.message || 'Unknown error'}`);
}

const issue = await response.json();

Check warning on line 153 in src/github-api.js

View workflow job for this annotation

GitHub Actions / test

'issue' is assigned a value but never used
return true;

} catch (error) {
Expand Down Expand Up @@ -185,7 +185,7 @@
throw new Error(`GitHub API error: ${response.status} - ${errorData.message || 'Unknown error'}`);
}

const issue = await response.json();

Check warning on line 188 in src/github-api.js

View workflow job for this annotation

GitHub Actions / test

'issue' is assigned a value but never used

// Update the stored raw description in the task element for future edits
const taskElement = document.querySelector(`[data-issue-number="${issueNumber}"]`);
Expand Down Expand Up @@ -397,15 +397,40 @@
// 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');
Expand All @@ -419,9 +444,9 @@
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}`);
Expand Down
10 changes: 8 additions & 2 deletions src/github-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
64 changes: 42 additions & 22 deletions tests/github-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions tests/github-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading