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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +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)
- **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). Live progress is reported through a `pr_radar_available_repos_progress` storage key the SW writes as it works (per-platform, plus the current org name for GitHub) and the popup watches via `chrome.storage.onChanged` — surfaced as "Loading GitHub repos… (org)"
- **Add repo by name** — The enumerated list is paginated (GitHub caps at ~2000 repos/list), so in very large orgs a repo may never appear. The Repos page has an "Add a repo by name" form: enter `owner/repo` (or a pasted URL), which is verified with a single API call via the `VERIFY_REPO` message (`github.getRepo` / `gitlab.getProject` / `bitbucket.getRepository`) and added straight to the watched list. `buildRepoList` unions watched-but-not-enumerated repos (enabled or pinned only) so added repos survive cache refreshes and don't resurrect stale disabled entries (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)
Expand Down
53 changes: 31 additions & 22 deletions src/background/service-worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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, saveCachedAvailableRepos } from '@/shared/storage';
import { getSettings, getAccounts, getWatchedRepos, getCachedPRs, saveCachedPRs, setInstallDate, getDeployHQAccount, saveDeployHQAccount, getDeployHQRepoMapping, saveDeployHQRepoMapping, savePollErrors, saveRateLimits, getRateLimits, saveAccount, setWhatsNewSeenVersion, saveCachedAvailableRepos, setAvailableReposProgress } from '@/shared/storage';
import type { AvailableRepo } from '@/shared/storage';
import * as github from '@/shared/api/github';
import * as gitlab from '@/shared/api/gitlab';
Expand Down Expand Up @@ -92,31 +92,40 @@ async function doRefreshAvailableRepos(): Promise<void> {
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 });
try {
for (const account of accounts) {
try {
if (account.platform === 'github') {
await setAvailableReposProgress({ platform: 'github' });
const ghRepos = await github.getUserRepos(account.token, (org) =>
setAvailableReposProgress({ platform: 'github', detail: org }),
);
for (const r of ghRepos) repos.push({ platform: 'github', fullName: r.full_name });
} else if (account.platform === 'gitlab') {
await setAvailableReposProgress({ 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') {
await setAvailableReposProgress({ 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);
}
} 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;
// 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 });
await saveCachedAvailableRepos({ repos, updatedAt: Date.now(), error });
} finally {
await setAvailableReposProgress(null);
}
}

// Deduplicate concurrent refreshes: a popup reopened mid-fetch awaits the same
Expand Down
48 changes: 40 additions & 8 deletions src/popup/pages/Repos.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { useState, useEffect } from 'react';
import type { Platform, WatchedRepo } from '@/shared/types';
import type { AvailableRepo } from '@/shared/storage';
import { getAccounts, getWatchedRepos, saveWatchedRepos, getCachedAvailableRepos } from '@/shared/storage';
import type { AvailableRepo, AvailableReposProgress } from '@/shared/storage';
import {
getAccounts,
getWatchedRepos,
saveWatchedRepos,
getCachedAvailableRepos,
getAvailableReposProgress,
AVAILABLE_REPOS_PROGRESS_KEY,
} from '@/shared/storage';
import PlatformIcon from '../components/PlatformIcon';

function formatProgress(p: AvailableReposProgress): string {
const label = p.platform === 'github' ? 'GitHub' : p.platform === 'gitlab' ? 'GitLab' : 'Bitbucket';
return p.detail ? `Loading ${label} repos… (${p.detail})` : `Loading ${label} repos…`;
}

// 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[] {
Expand Down Expand Up @@ -57,6 +69,7 @@ export default function Repos() {
const [repos, setRepos] = useState<WatchedRepo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [progress, setProgress] = useState<AvailableReposProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all');
Expand All @@ -70,16 +83,28 @@ export default function Repos() {
useEffect(() => {
let cancelled = false;

// Live progress from the background fetch ("Loading GitHub repos… (org)").
function onStorageChange(
changes: Record<string, chrome.storage.StorageChange>,
area: string,
) {
if (area !== 'local' || !changes[AVAILABLE_REPOS_PROGRESS_KEY]) return;
setProgress((changes[AVAILABLE_REPOS_PROGRESS_KEY].newValue as AvailableReposProgress) ?? null);
}
chrome.storage.onChanged.addListener(onStorageChange);

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([
const [accounts, cache, watched, initialProgress] = await Promise.all([
getAccounts(),
getCachedAvailableRepos(),
getWatchedRepos(),
getAvailableReposProgress(),
]);
if (cancelled) return;
setConnectedPlatforms(new Set(accounts.map((a) => a.platform)));
setProgress(initialProgress);
if (cache) {
setRepos(buildRepoList(cache.repos, watched));
setError(cache.error ?? null);
Expand Down Expand Up @@ -107,11 +132,13 @@ export default function Repos() {
}
setLoading(false);
setRefreshing(false);
setProgress(null);
}

run();
return () => {
cancelled = true;
chrome.storage.onChanged.removeListener(onStorageChange);
};
}, []);

Expand Down Expand Up @@ -253,11 +280,8 @@ export default function Repos() {
</div>
)}
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-gray-500" aria-live="polite">
<p className="text-[11px] text-gray-500">
{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 @@ -268,6 +292,11 @@ export default function Repos() {
</button>
)}
</div>
{refreshing && repos.length > 0 && (
<p className="text-[11px] text-gray-400 mt-1 truncate" aria-live="polite">
{progress ? formatProgress(progress) : 'Updating…'}
</p>
)}

{connectedPlatforms.size > 0 && (
<div className="mt-2">
Expand Down Expand Up @@ -350,8 +379,11 @@ export default function Repos() {

<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-16" role="status" aria-label="Loading repositories">
<div className="flex flex-col items-center justify-center gap-2 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" />
<p className="text-[11px] text-gray-500 truncate max-w-[80%]" aria-live="polite">
{progress ? formatProgress(progress) : 'Loading repositories…'}
</p>
</div>
) : error && repos.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-red-500 dark:text-red-400">
Expand Down
6 changes: 5 additions & 1 deletion src/shared/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ export async function getRepo(token: string, fullName: string): Promise<{ full_n
}
}

export async function getUserRepos(token: string): Promise<{ full_name: string }[]> {
export async function getUserRepos(
token: string,
onProgress?: (detail: string) => void,
): Promise<{ full_name: string }[]> {
// Fetch personal repos
const userRepos = await ghPaginate<{ full_name: string }>(
'/user/repos?sort=pushed&per_page=100&affiliation=owner,collaborator,organization_member',
Expand All @@ -196,6 +199,7 @@ export async function getUserRepos(token: string): Promise<{ full_name: string }
orgs,
ORG_FETCH_CONCURRENCY,
async (org) => {
onProgress?.(org.login);
const [memberRepos, publicRepos] = await Promise.all([
ghPaginate<{ full_name: string }>(
`/orgs/${org.login}/repos?sort=pushed&per_page=100&type=member`,
Expand Down
25 changes: 25 additions & 0 deletions src/shared/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ export async function saveCachedAvailableRepos(cache: AvailableReposCache): Prom
await chrome.storage.local.set({ [AVAILABLE_REPOS_CACHE_KEY]: cache });
}

// Live progress for the background available-repos fetch, so the Repos page can
// show "Loading GitHub repos… (some-org)" instead of an opaque spinner. Written
// by the service worker and watched by the popup via chrome.storage.onChanged.
export const AVAILABLE_REPOS_PROGRESS_KEY = 'pr_radar_available_repos_progress';

export interface AvailableReposProgress {
platform: Platform;
// Optional finer-grained context, e.g. the org currently being fetched.
detail?: string;
}

export async function setAvailableReposProgress(progress: AvailableReposProgress | null): Promise<void> {
if (progress) {
await chrome.storage.local.set({ [AVAILABLE_REPOS_PROGRESS_KEY]: progress });
} else {
await chrome.storage.local.remove(AVAILABLE_REPOS_PROGRESS_KEY);
}
}

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

// === PR cache ===

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

Expand Down
Loading