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
48 changes: 33 additions & 15 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
if (msg.type === 'PIN_IDEA' && msg.sessionKey && msg.idea && Array.isArray(msg.idea.sources)) {
sendResponse({ ok: true });
(async () => {
const cur = (await chrome.storage.session.get(msg.sessionKey))[msg.sessionKey] || {};

Check warning on line 263 in background.js

View workflow job for this annotation

GitHub Actions / test

'cur' is assigned a value but never used. Allowed unused vars must match /^_/u
await pinIdea({ ...msg.idea, createdIso: new Date().toISOString() });
})();
return true;
Expand Down Expand Up @@ -318,7 +318,7 @@
try {
const persisted = await chrome.storage.local.get(`repolens_ask_${cur.repoId}`);
sessionHistory = persisted[`repolens_ask_${cur.repoId}`] || [];
} catch (_) {}

Check warning on line 321 in background.js

View workflow job for this annotation

GitHub Actions / test

'_' is defined but never used
}
const history = sessionHistory.slice(-4); // keep last 4 completed pairs for AI context
await setAsk({ pending: { status: 'thinking', question: msg.question }, history });
Expand Down Expand Up @@ -444,7 +444,7 @@
async function handleOpenAIOAuthCallback(rawUrl, tabId) {
if (!rawUrl || !isOpenAIOAuthCallbackUrl(rawUrl)) return;

console.log('[RepoLens OAuth] OpenAI callback detected:', rawUrl.split('?')[0]); // strip ?code=…

Check warning on line 447 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement

let url;
try {
Expand All @@ -467,7 +467,7 @@

if (error) {
const msg = errorDesc || error;
console.warn('[RepoLens OAuth] OpenAI provider returned error:', msg);

Check warning on line 470 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement
await chrome.storage.local.set({ [OPENAI_OAUTH_ERROR_KEY]: `ChatGPT sign-in error: ${msg}` });
await cleanupFlowMarkers();
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
Expand All @@ -484,7 +484,7 @@
const storedState = stored[OPENAI_OAUTH_STATE_KEY];

if (!verifier) {
console.warn('[RepoLens OAuth] No stored OpenAI verifier — flow interrupted or for another extension');

Check warning on line 487 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement
await cleanupFlowMarkers(); // clear any stale state marker from an interrupted flow
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
return;
Expand All @@ -495,11 +495,11 @@
// Mint a usable API key so scans run through the ordinary OpenAI engine.
const apiKey = await mintOpenAIApiKey(creds.id_token);
await chrome.storage.local.set({ openaiKey: apiKey });
console.log('[RepoLens OAuth] OpenAI success — signed in via ChatGPT');

Check warning on line 498 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement
await cleanupFlowMarkers();
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
} catch (err) {
console.error('[RepoLens OAuth] OpenAI exchange error:', err.message);

Check warning on line 502 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement
// No usable key ⇒ don't leave half-finished OAuth state that reads as "connected".
await clearOpenAICredentials().catch(() => {});
await chrome.storage.local.set({ [OPENAI_OAUTH_ERROR_KEY]: err.message });
Expand Down Expand Up @@ -1260,6 +1260,24 @@
});
}

// Every provider request runs under a hard timeout. Without it a stalled connection
// hangs the (serialized) scan indefinitely — until the output tab's own 90s deadline
// fires with a generic "Analysis timed out". This aborts at the source, surfaces an
// actionable Retry, and lets the attempt plan fall through to the next provider.
const AI_FETCH_TIMEOUT_MS = 60_000;
async function fetchWithTimeout(url, opts = {}, label = 'Provider', ms = AI_FETCH_TIMEOUT_MS) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(url, { ...opts, signal: ctrl.signal });
} catch (e) {
if (e && e.name === 'AbortError') throw new Error(`${label} timed out after ${Math.round(ms / 1000)}s`);
throw e;
} finally {
clearTimeout(timer);
}
}

// OpenAI-compatible chat completion. `key` may be empty for keyless local servers (Ollama).
// headerStyle 'azure' sends `api-key: <key>` (Azure OpenAI); otherwise `Authorization: Bearer`.
async function callOpenAICompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096, headerStyle = 'bearer' }) {
Expand All @@ -1268,11 +1286,11 @@
if (headerStyle === 'azure') headers['api-key'] = key;
else headers['Authorization'] = `Bearer ${key}`;
}
const res = await fetch(endpoint, {
const res = await fetchWithTimeout(endpoint, {
method: 'POST',
headers,
body: JSON.stringify(openaiBody(model, prompt, maxTokens)),
});
}, label);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`);
Expand Down Expand Up @@ -1313,16 +1331,16 @@

// Bare OpenAI chat request returning the raw Response, so callers can branch on 401.
function openaiChat(key, model, prompt) {
return fetch('https://api.openai.com/v1/chat/completions', {
return fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(openaiBody(model, prompt, 4096)),
});
}, 'OpenAI');
}

// Anthropic-compatible Messages API (x-api-key + anthropic-version).
async function callAnthropicCompatible({ endpoint, key, model, prompt, label = 'Provider', maxTokens = 4096 }) {
const res = await fetch(endpoint, {
const res = await fetchWithTimeout(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -1331,7 +1349,7 @@
'x-api-key': key,
},
body: JSON.stringify(anthropicBody(model, prompt, maxTokens)),
});
}, label);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? err.message ?? `${label} API error ${res.status}`);
Expand Down Expand Up @@ -1407,7 +1425,7 @@
const { anthropicKey } = await chrome.storage.local.get('anthropicKey');
if (!anthropicKey) throw new Error('No Anthropic API key — add one in Settings');

const res = await fetch('https://api.anthropic.com/v1/messages', {
const res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'anthropic-version': '2023-06-01',
Expand All @@ -1420,7 +1438,7 @@
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }]
})
});
}, 'Anthropic');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? `Anthropic API error ${res.status}`);
Expand All @@ -1433,14 +1451,14 @@

async function callGemini(key, model = 'gemini-2.5-flash', prompt) {
const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + encodeURIComponent(model) + ':generateContent?key=' + encodeURIComponent(key);
const res = await fetch(url, {
const res = await fetchWithTimeout(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: { responseMimeType: 'application/json', maxOutputTokens: 4096 }
})
});
}, 'Gemini');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? `Gemini API error ${res.status}`);
Expand All @@ -1466,11 +1484,11 @@
});

for (let attempt = 0; ; attempt++) {
const res = await fetch('https://inference-api.nousresearch.com/v1/chat/completions', {
const res = await fetchWithTimeout('https://inference-api.nousresearch.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
body,
});
}, 'Nous');

// Rate-limited / transient — back off and retry (honor Retry-After), up to 3 times.
if ((res.status === 429 || res.status === 503) && attempt < 3) {
Expand Down Expand Up @@ -1498,15 +1516,15 @@
}

async function callOpenRouter(key, model = 'x-ai/grok-4.3', prompt) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
const res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: model || 'x-ai/grok-4.3',
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }]
})
});
}, 'OpenRouter');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message ?? `OpenRouter API error ${res.status}`);
Expand Down Expand Up @@ -1546,7 +1564,7 @@
reqHeaders['x-grok-model-override'] = model || 'grok-4.3';
reqHeaders['user-agent'] = 'xai-grok-cli';
}
const res = await fetch(endpoint, { method: 'POST', headers: reqHeaders, body });
const res = await fetchWithTimeout(endpoint, { method: 'POST', headers: reqHeaders, body }, 'xAI');
if (res.ok) {
const data = await res.json();
const text = data.choices?.[0]?.message?.content;
Expand All @@ -1554,7 +1572,7 @@
throw new Error('xAI returned no text content');
}
const err = await res.json().catch(() => ({}));
console.warn('[RepoLens xAI]', endpoint, res.status, JSON.stringify(err));

Check warning on line 1575 in background.js

View workflow job for this annotation

GitHub Actions / test

Unexpected console statement
lastErr = err.error?.message || ('xAI API error ' + res.status + ' at ' + endpoint);
if (res.status === 401 && isOAuth) {
await chrome.storage.local.remove(['xaiKey', 'xaiRefresh', 'xaiExpiry', 'xaiCredentials']);
Expand Down
5 changes: 5 additions & 0 deletions errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const KIND_META = {
rate_limit: { retryable: true, fixable: false, priority: 2 },
server: { retryable: true, fixable: false, priority: 1 },
network: { retryable: true, fixable: false, priority: 1 },
// A client-side request timeout: NOT retryable, so the attempt plan falls
// straight to the next provider rather than re-hammering a stalled one.
timeout: { retryable: false, fixable: false, priority: 1 },
unknown: { retryable: false, fixable: false, priority: 0 },
};

Expand All @@ -37,6 +40,7 @@ function humanize(kind, provider, fallback) {
case 'rate_limit': return `${who} is rate-limited — wait a moment, or route this part to another provider.`;
case 'not_found': return `${who} didn’t recognize that model — pick a valid model in Settings.`;
case 'server': return `${who} is temporarily unavailable — retried and still failing. Try again shortly.`;
case 'timeout': return `${who} took too long to respond. Try again, or route this part to a faster provider in Settings.`;
case 'network': return `Couldn’t reach ${provider || 'the provider'} — check your connection and retry.`;
case 'bad_request': return fallback || `${who} rejected the request as malformed.`;
default: return fallback || 'Something went wrong with the AI request.';
Expand All @@ -61,6 +65,7 @@ export function categorizeError(err, provider = '') {
else if (status === 404 || /not found|unknown model|no such model|does not exist/i.test(low)) kind = 'not_found';
else if (status >= 500 || /server error|unavailable|bad gateway|gateway timeout|overloaded/i.test(low)) kind = 'server';
else if (status === 400 || /bad request|invalid request/i.test(low)) kind = 'bad_request';
else if (/timed out after \d+\s*s\b/i.test(low)) kind = 'timeout';
else if (/network|failed to fetch|fetch failed|timeout|timed out|connection refused/i.test(low)) kind = 'network';

const meta = KIND_META[kind];
Expand Down
1 change: 1 addition & 0 deletions output-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ async function init() {
not_found: 'The model name is unrecognised. Open Settings and pick a valid model from the dropdown.',
network: 'Can\'t reach the provider. Check your internet connection, then retry.',
server: 'The provider is temporarily down. Retry in a few seconds.',
timeout: 'The provider took too long. Retry, or pick a faster model/provider in Settings.',
};
const hint = HINTS[kind];
let hintEl = document.getElementById('error-hint');
Expand Down
6 changes: 6 additions & 0 deletions tests/errors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ describe('categorizeError', () => {
expect(categorizeError('Failed to fetch').kind).toBe('network');
expect(categorizeError('overloaded').retryable).toBe(true);
});
it('classifies a client request timeout as a non-retryable timeout (falls to next provider)', () => {
const r = categorizeError(new Error('Anthropic timed out after 60s'), 'Anthropic');
expect(r.kind).toBe('timeout');
expect(r.retryable).toBe(false); // don't re-hammer a stalled provider
expect(r.userMessage).toMatch(/took too long/i);
});
it('classifies an unknown model as a fixable not_found', () => {
const r = categorizeError(new Error('model claude-x does not exist'), 'Anthropic');
expect(r.kind).toBe('not_found');
Expand Down
7 changes: 6 additions & 1 deletion tests/maintenance.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { bandFromSignals, daysSincePush, ciSignals, buildMaintenancePrompt, parseMaintenance, MAINT_BANDS, BUS_FACTORS } from '../maintenance.js';

const NOW = new Date('2026-06-13T00:00:00Z').getTime();
const daysAgo = (n) => new Date(NOW - n * 86400000).toISOString();

// bandFromSignals/daysSincePush read the real Date.now(); pin it to NOW so the
// day-based assertions stay deterministic regardless of when the suite runs.
beforeAll(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-13T00:00:00Z')); });
afterAll(() => { vi.useRealTimers(); });

const signals = {
pushedAt: daysAgo(45),
archived: false,
Expand Down
Loading