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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ To update: rebuild, then click refresh on the extension card (or remove + re-add
```
src/
background/
service-worker.ts # Polling, badge updates, notifications, sound trigger
service-worker.ts # Polling, badge updates, notifications, sound trigger, background available-repo fetch (cached, dedup'd)
popup/
App.tsx # Main app shell with routing
pages/
Setup.tsx # Multi-platform connection (GitHub/GitLab/Bitbucket PAT auth, scope guide, connected state)
Dashboard.tsx # PR list with tabs (Mine/Review/All), cache-first rendering
Settings.tsx # Notifications, sound, polling, stale PR config, accounts, test button
Repos.tsx # Watched repo selector with platform filter, select all, pin/fav stars, token scope callouts
Repos.tsx # Watched repo selector with platform filter, select all, pin/fav stars, token scope callouts; cache-first, refreshes via background service worker
components/
Header.tsx # Navigation header with extension icon + "by DeployHQ"
PRItem.tsx # PR row: badges, diff stats, description preview, deployment URL, pinned star, stale/reviewed dimming
Expand All @@ -96,7 +96,7 @@ src/
shared/
types.ts # TypeScript types (PullRequest, Platform, Message, UrgencyCategory, etc.)
constants.ts # Status colors, platform labels, sound options
storage.ts # Chrome storage wrapper (accounts, settings, repos, PR cache, CI statuses)
storage.ts # Chrome storage wrapper (accounts, settings, repos, PR cache, available-repos cache, CI statuses)
api/
github.ts # GitHub REST + GraphQL API (PRs, CI, reviews, threads, deployments, orgs, merge)
gitlab.ts # GitLab REST API (MRs, CI pipelines, discussions, approvals, deployments, merge)
Expand Down Expand Up @@ -158,6 +158,7 @@ The extension works fully without DeployHQ. This integration is entirely opt-in
- **No backend** — PATs stored in `chrome.storage.local`, all API calls direct from browser
- **No tab required** — Background polling via service worker + `chrome.alarms`
- **Cache-first rendering** — PR data cached in `chrome.storage`; popup shows cache instantly, refreshes in background with "Updating..." indicator
- **Background available-repo fetch** — The Repos page's list of watchable repos (personal + every org's repos, per platform) is slow to fetch, so the service worker fetches it (`FETCH_AVAILABLE_REPOS` message, dedup'd via an in-flight promise) and caches it in `chrome.storage`. The popup renders cache-first and shows "Updating…"; because the fetch runs in the SW it survives the popup closing. A refresh that loads zero repos across all accounts caches an error the page surfaces instead of an endless spinner (issue #23)
- **Persisted CI statuses** — Stored in `chrome.storage` (not in-memory) so status change detection survives service worker restarts
- **Offscreen API for audio** — MV3 service workers can't play audio; uses `public/offscreen.html` + `public/offscreen.js` (no inline scripts due to CSP)
- **GraphQL for comments** — REST API doesn't expose thread resolution; GraphQL `reviewThreads.isResolved` is accurate
Expand Down
56 changes: 55 additions & 1 deletion src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PullRequest, CIStatus, Message, PollError, PollErrorKind, Platform, RateLimitInfo } from '@/shared/types';
import { CI_STATUS_LABELS } from '@/shared/constants';
import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion } from '@/shared/storage';
import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion, saveCachedAvailableRepos } from '@/shared/storage';
import type { AvailableRepo } from '@/shared/storage';
import * as github from '@/shared/api/github';
import * as gitlab from '@/shared/api/gitlab';
import * as bitbucket from '@/shared/api/bitbucket';
Expand Down Expand Up @@ -79,10 +80,63 @@ chrome.alarms.onAlarm.addListener((alarm) => {
}
});

// === Available repos ===
// Fetching the full list of watchable repos is slow, so we do it here in the
// service worker (not the popup) and cache the result. Running it here means the
// fetch keeps going even after the popup closes. See issue #23.

let availableReposRefresh: Promise<void> | null = null;

async function doRefreshAvailableRepos(): Promise<void> {
const accounts = await getAccounts();
const repos: AvailableRepo[] = [];
const failed: Platform[] = [];

for (const account of accounts) {
try {
if (account.platform === 'github') {
const ghRepos = await github.getUserRepos(account.token);
for (const r of ghRepos) repos.push({ platform: 'github', fullName: r.full_name });
} else if (account.platform === 'gitlab') {
const glRepos = await gitlab.getUserProjects(account.token);
for (const r of glRepos) repos.push({ platform: 'gitlab', fullName: r.path_with_namespace });
} else if (account.platform === 'bitbucket') {
const bbRepos = await bitbucket.getUserRepositories(account.token);
for (const r of bbRepos) repos.push({ platform: 'bitbucket', fullName: r.full_name });
}
} catch (err) {
console.error(`[PR Radar] Failed to fetch repos for ${account.platform}:`, err);
failed.push(account.platform);
}
}

// Only surface an error when nothing loaded — a partial failure still shows
// the repos we did get.
const error = repos.length === 0 && failed.length > 0
? `Couldn't load repos for: ${failed.join(', ')}`
: undefined;

await saveCachedAvailableRepos({ repos, updatedAt: Date.now(), error });
}

// Deduplicate concurrent refreshes: a popup reopened mid-fetch awaits the same
// in-flight run rather than kicking off a second one.
function refreshAvailableRepos(): Promise<void> {
if (!availableReposRefresh) {
availableReposRefresh = doRefreshAvailableRepos().finally(() => {
availableReposRefresh = null;
});
}
return availableReposRefresh;
}

chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
if (message.type === 'POLL_NOW') {
pollPRs().then(() => sendResponse({ done: true }));
return true; // keep channel open for async sendResponse
} else if (message.type === 'FETCH_AVAILABLE_REPOS') {
refreshAvailableRepos().then(() => sendResponse({ done: true }));
return true; // keep channel open for async sendResponse
Comment on lines +122 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A8 'export (async )?function getUserRepos|export (async )?function getUserProjects|export (async )?function getUserRepositories' src/shared/api
rg -n 'AbortController|signal:|timeout' src/shared/api

Repository: deployhq/pr-radar

Length of output: 2286


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- service worker outline ---'
ast-grep outline src/background/service-worker.ts --view expanded || true

echo '--- popup Repos outline ---'
ast-grep outline src/popup/Repos.tsx --view expanded || true

echo '--- shared api outlines ---'
ast-grep outline src/shared/api/github.ts --view expanded || true
ast-grep outline src/shared/api/gitlab.ts --view expanded || true
ast-grep outline src/shared/api/bitbucket.ts --view expanded || true

echo '--- relevant ranges: service worker ---'
sed -n '1,220p' src/background/service-worker.ts | cat -n

echo '--- relevant ranges: Repos popup ---'
sed -n '1,260p' src/popup/Repos.tsx | cat -n

echo '--- relevant ranges: github api ---'
sed -n '1,280p' src/shared/api/github.ts | cat -n

echo '--- relevant ranges: gitlab api ---'
sed -n '1,220p' src/shared/api/gitlab.ts | cat -n

echo '--- relevant ranges: bitbucket api ---'
sed -n '1,220p' src/shared/api/bitbucket.ts | cat -n

Repository: deployhq/pr-radar

Length of output: 21325


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- locate popup repo component ---'
fd -HI 'Repos.tsx|Repos.ts|repos.tsx|repos.ts' src

echo '--- locate FETCH_AVAILABLE_REPOS sendMessage ---'
rg -n -A4 -B4 "FETCH_AVAILABLE_REPOS|refreshAvailableRepos|sendMessage\\(" src

echo '--- github fetch wrappers ---'
sed -n '1,120p' src/shared/api/github.ts | cat -n

echo '--- gitlab fetch wrappers ---'
sed -n '1,80p' src/shared/api/gitlab.ts | cat -n

echo '--- bitbucket fetch wrappers ---'
sed -n '1,60p' src/shared/api/bitbucket.ts | cat -n

Repository: deployhq/pr-radar

Length of output: 20501


Add a timeout to the available-repos refresh

doRefreshAvailableRepos() already handles per-account API failures, but src/shared/api/github.ts, src/shared/api/gitlab.ts, and src/shared/api/bitbucket.ts still use plain fetch() with no timeout/abort path. One stalled request can leave FETCH_AVAILABLE_REPOS pending and the popup stuck in refreshing; wrap those calls (or the popup sendMessage) with an AbortController/timeout so the refresh fails closed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/background/service-worker.ts` around lines 122 - 139, The
FETCH_AVAILABLE_REPOS path in refreshAvailableRepos/doRefreshAvailableRepos can
hang indefinitely because the underlying API calls still rely on plain fetch()
without any abort or timeout. Add a timeout-based AbortController around the
available-repos refresh flow (either inside the shared GitHub/GitLab/Bitbucket
request helpers or at the service-worker message handling boundary) so stalled
requests are cancelled and the popup can recover instead of մն staying in
refreshing. Keep the change localized to the refreshAvailableRepos,
doRefreshAvailableRepos, and relevant API helper methods.

} else if (message.type === 'REFRESH_SETTINGS') {
setupPolling();
} else if (message.type === 'MERGE_PR') {
Expand Down
137 changes: 74 additions & 63 deletions src/popup/pages/Repos.tsx
Original file line number Diff line number Diff line change
@@ -1,82 +1,86 @@
import { useState, useEffect } from 'react';
import type { Platform, WatchedRepo } from '@/shared/types';
import { getAccounts, getWatchedRepos, saveWatchedRepos } from '@/shared/storage';
import * as github from '@/shared/api/github';
import * as gitlab from '@/shared/api/gitlab';
import * as bitbucket from '@/shared/api/bitbucket';
import type { AvailableRepo } from '@/shared/storage';
import { getAccounts, getWatchedRepos, saveWatchedRepos, getCachedAvailableRepos } from '@/shared/storage';
import PlatformIcon from '../components/PlatformIcon';

// Merge the cached available-repo list with saved watch state, then sort:
// pinned+enabled first, then enabled, then the rest — alphabetical within each group.
function buildRepoList(available: AvailableRepo[], watched: WatchedRepo[]): WatchedRepo[] {
const watchedMap = new Map(watched.map((r) => [`${r.platform}:${r.fullName}`, r]));
const list = available.map((r) => {
const saved = watchedMap.get(`${r.platform}:${r.fullName}`);
return {
platform: r.platform,
fullName: r.fullName,
enabled: saved?.enabled ?? false,
pinned: saved?.pinned ?? false,
} satisfies WatchedRepo;
});
list.sort((a, b) => {
const aRank = a.enabled && a.pinned ? 0 : a.enabled ? 1 : 2;
const bRank = b.enabled && b.pinned ? 0 : b.enabled ? 1 : 2;
if (aRank !== bRank) return aRank - bRank;
return a.fullName.localeCompare(b.fullName);
});
return list;
}

export default function Repos() {
const [repos, setRepos] = useState<WatchedRepo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all');
const [connectedPlatforms, setConnectedPlatforms] = useState<Set<Platform>>(new Set());

useEffect(() => {
async function load() {
const [accounts, watched] = await Promise.all([getAccounts(), getWatchedRepos()]);
const watchedMap = new Map(watched.map((r) => [`${r.platform}:${r.fullName}`, r]));
let cancelled = false;

const allRepos: WatchedRepo[] = [];

for (const account of accounts) {
try {
if (account.platform === 'github') {
const ghRepos = await github.getUserRepos(account.token);
for (const r of ghRepos) {
const key = `github:${r.full_name}`;
const saved = watchedMap.get(key);
allRepos.push({
platform: 'github',
fullName: r.full_name,
enabled: saved?.enabled ?? false,
pinned: saved?.pinned ?? false,
});
}
} else if (account.platform === 'gitlab') {
const glRepos = await gitlab.getUserProjects(account.token);
for (const r of glRepos) {
const key = `gitlab:${r.path_with_namespace}`;
const saved = watchedMap.get(key);
allRepos.push({
platform: 'gitlab',
fullName: r.path_with_namespace,
enabled: saved?.enabled ?? false,
pinned: saved?.pinned ?? false,
});
}
} else if (account.platform === 'bitbucket') {
const bbRepos = await bitbucket.getUserRepositories(account.token);
for (const r of bbRepos) {
const key = `bitbucket:${r.full_name}`;
const saved = watchedMap.get(key);
allRepos.push({
platform: 'bitbucket',
fullName: r.full_name,
enabled: saved?.enabled ?? false,
pinned: saved?.pinned ?? false,
});
}
}
} catch (err) {
console.error(`Failed to fetch repos for ${account.platform}:`, err);
}
async function run() {
// Cache-first: render whatever we have instantly so reopening the popup is
// never a blank spinner.
const [accounts, cache, watched] = await Promise.all([
getAccounts(),
getCachedAvailableRepos(),
getWatchedRepos(),
]);
if (cancelled) return;
setConnectedPlatforms(new Set(accounts.map((a) => a.platform)));
if (cache) {
setRepos(buildRepoList(cache.repos, watched));
setError(cache.error ?? null);
setLoading(false);
}

// Sort: pinned+enabled first, then enabled, then disabled — alphabetical within each group
allRepos.sort((a, b) => {
const aRank = a.enabled && a.pinned ? 0 : a.enabled ? 1 : 2;
const bRank = b.enabled && b.pinned ? 0 : b.enabled ? 1 : 2;
if (aRank !== bRank) return aRank - bRank;
return a.fullName.localeCompare(b.fullName);
});

setRepos(allRepos);
setConnectedPlatforms(new Set(accounts.map((a) => a.platform)));
// Refresh via the service worker so the (slow) fetch survives the popup
// being closed — it keeps running in the background and writes to the
// cache, which we re-read once it resolves. See issue #23.
setRefreshing(true);
try {
await chrome.runtime.sendMessage({ type: 'FETCH_AVAILABLE_REPOS' });
} catch {
// Service worker unreachable; fall back to whatever cache we rendered.
}
if (cancelled) return;
const [freshCache, freshWatched] = await Promise.all([
getCachedAvailableRepos(),
getWatchedRepos(),
]);
if (cancelled) return;
if (freshCache) {
setRepos(buildRepoList(freshCache.repos, freshWatched));
setError(freshCache.error ?? null);
}
setLoading(false);
setRefreshing(false);
}
load();

run();
return () => {
cancelled = true;
};
}, []);

async function handleToggle(fullName: string, platform: string) {
Expand Down Expand Up @@ -170,8 +174,11 @@ export default function Repos() {
</div>
)}
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-gray-500">
<p className="text-[11px] text-gray-500" aria-live="polite">
{enabledCount} of {repos.length} repos watched
{refreshing && repos.length > 0 && (
<span className="ml-1.5 text-gray-400">· Updating…</span>
)}
</p>
{filtered.length > 0 && (
<button
Expand All @@ -189,6 +196,10 @@ export default function Repos() {
<div className="flex items-center justify-center py-16" role="status" aria-label="Loading repositories">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-radar-500 border-t-transparent" />
</div>
) : error && repos.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-red-500 dark:text-red-400">
{error}
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-gray-500">
{filter ? 'No repos match your filter' : 'No repos found'}
Expand Down
33 changes: 33 additions & 0 deletions src/shared/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ export async function saveWatchedRepos(repos: WatchedRepo[]): Promise<void> {
await chrome.storage.local.set({ [REPOS_KEY]: repos });
}

// === Available repos cache ===
// The full list of repos the user *could* watch. Fetching it is slow (personal
// repos + every org's repos, across each connected platform), so the background
// service worker fetches it and caches the result here. The Repos page renders
// from this cache instantly and lets the refresh run in the background — which
// means it survives the popup being closed. See issue #23.

export const AVAILABLE_REPOS_CACHE_KEY = 'pr_radar_available_repos';

export interface AvailableRepo {
platform: Platform;
fullName: string;
}

export interface AvailableReposCache {
repos: AvailableRepo[];
updatedAt: number;
// Set only when nothing could be loaded (every connected account errored), so
// the popup can show an error instead of an endless spinner. A partial failure
// still caches the repos that did load and leaves this undefined.
error?: string;
}

export async function getCachedAvailableRepos(): Promise<AvailableReposCache | null> {
const result = await chrome.storage.local.get(AVAILABLE_REPOS_CACHE_KEY);
return result[AVAILABLE_REPOS_CACHE_KEY] ?? null;
}

export async function saveCachedAvailableRepos(cache: AvailableReposCache): Promise<void> {
await chrome.storage.local.set({ [AVAILABLE_REPOS_CACHE_KEY]: cache });
}

Comment on lines +105 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move AvailableRepo/AvailableReposCache domain types into types.ts.

These are domain types (analogous to WatchedRepo, which already lives in types.ts), but they're defined here in storage.ts instead. Repos.tsx even imports AvailableRepo from @/shared/storage rather than @/shared/types, which is inconsistent with how other domain types are sourced.

♻️ Suggested move
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
+export interface AvailableRepo {
+  platform: Platform;
+  fullName: string;
+}
+
+export interface AvailableReposCache {
+  repos: AvailableRepo[];
+  updatedAt: number;
+  error?: string;
+}
--- a/src/shared/storage.ts
+++ b/src/shared/storage.ts
-export interface AvailableRepo {
-  platform: Platform;
-  fullName: string;
-}
-
-export interface AvailableReposCache {
-  repos: AvailableRepo[];
-  updatedAt: number;
-  // Set only when nothing could be loaded ...
-  error?: string;
-}
+import type { AvailableRepo, AvailableReposCache } from './types';

As per path instructions, "In TypeScript source files, use the shared types.ts and constants.ts definitions for domain types, status colors, platform labels, and sound options."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/storage.ts` around lines 105 - 136, Move the domain types
`AvailableRepo` and `AvailableReposCache` out of `storage.ts` and into
`types.ts`, matching how `WatchedRepo` is defined and consumed elsewhere. Update
`getCachedAvailableRepos` and `saveCachedAvailableRepos` in `storage.ts` to
import the types from `@/shared/types`, and change any imports such as in
`Repos.tsx` to reference `AvailableRepo` from `@/shared/types` instead of
`@/shared/storage`. Keep `AVAILABLE_REPOS_CACHE_KEY` and the storage helpers in
`storage.ts`; only the type definitions should move.

Source: Path instructions

// === PR cache ===

import type { PullRequest } from './types';
Expand Down Expand Up @@ -211,6 +243,7 @@ export async function clearAll(): Promise<void> {
POLL_ERRORS_DISMISSED_KEY,
RATE_LIMIT_DISMISSED_KEY,
AI_SUMMARY_CACHE_KEY,
AVAILABLE_REPOS_CACHE_KEY,
]);
}

Expand Down
3 changes: 2 additions & 1 deletion src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,5 @@ export type Message =
| { type: 'TEST_DEPLOYHQ'; payload: { slug: string; email: string; apiKey: string } }
| { type: 'GET_DEPLOYHQ_SERVERS'; payload: { repoFullName: string } }
| { type: 'CREATE_DEPLOYHQ_DEPLOYMENT'; payload: { repoFullName: string; serverIdentifier: string } }
| { type: 'GET_PR_THREADS'; payload: { platform: Platform; repoFullName: string; prNumber: number } };
| { type: 'GET_PR_THREADS'; payload: { platform: Platform; repoFullName: string; prNumber: number } }
| { type: 'FETCH_AVAILABLE_REPOS' };
Loading