diff --git a/CLAUDE.md b/CLAUDE.md index 7e5127a..f75bdc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ src/ 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; cache-first, refreshes via background service worker + Repos.tsx # Watched repo selector with platform filter, select all, pin/fav stars, token scope callouts, add-repo-by-name (large orgs); 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 @@ -159,6 +159,7 @@ The extension works fully without DeployHQ. This integration is entirely opt-in - **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) +- **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) - **GraphQL for comments** — REST API doesn't expose thread resolution; GraphQL `reviewThreads.isResolved` is accurate @@ -189,6 +190,7 @@ The extension works fully without DeployHQ. This integration is entirely opt-in - **Manual refresh** — ↻ button in dashboard - **Last updated** — Timestamp shown below search bar - **Select all/deselect all** — In watched repo selector (entire row clickable) +- **Add repo by name** — Form in the repo selector to watch a repo by typing `owner/repo` (or pasting its URL); verified with one API call. Lets users in very large orgs watch repos that fall beyond the paginated list - **Token guidance** — Pre-filled token links, required scopes panel, platform-specific "Missing repos?" callouts - **Dark scrollbar** — Themed to match dark UI - **Merge PRs** — Merge button with confirm/cancel for GitHub, GitLab, and Bitbucket; disabled for drafts, conflicts, CI failures diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 17cbe01..ff81894 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -137,6 +137,34 @@ chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) = } else if (message.type === 'FETCH_AVAILABLE_REPOS') { refreshAvailableRepos().then(() => sendResponse({ done: true })); return true; // keep channel open for async sendResponse + } else if (message.type === 'VERIFY_REPO') { + const { platform, fullName } = message.payload; + (async () => { + const accounts = await getAccounts(); + const account = accounts.find((a) => a.platform === platform); + if (!account) { + sendResponse({ success: false, message: `Not connected to ${platform}` }); + return; + } + try { + let canonical: string | null = null; + if (platform === 'github') { + canonical = (await github.getRepo(account.token, fullName))?.full_name ?? null; + } else if (platform === 'gitlab') { + canonical = (await gitlab.getProject(account.token, fullName))?.path_with_namespace ?? null; + } else { + canonical = (await bitbucket.getRepository(account.token, fullName))?.full_name ?? null; + } + if (!canonical) { + sendResponse({ success: false, message: 'Repo not found or not accessible with your token' }); + return; + } + sendResponse({ success: true, fullName: canonical }); + } catch (err) { + sendResponse({ success: false, message: err instanceof Error ? err.message : 'Verification failed' }); + } + })(); + return true; // keep channel open for async sendResponse } else if (message.type === 'REFRESH_SETTINGS') { setupPolling(); } else if (message.type === 'MERGE_PR') { diff --git a/src/popup/pages/Repos.tsx b/src/popup/pages/Repos.tsx index 2b8322d..5834846 100644 --- a/src/popup/pages/Repos.tsx +++ b/src/popup/pages/Repos.tsx @@ -8,15 +8,31 @@ import PlatformIcon from '../components/PlatformIcon'; // 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 { + const seen = new Set(); + const list: WatchedRepo[] = []; + + for (const r of available) { + const key = `${r.platform}:${r.fullName}`; + seen.add(key); + const saved = watchedMap.get(key); + list.push({ platform: r.platform, fullName: r.fullName, enabled: saved?.enabled ?? false, pinned: saved?.pinned ?? false, - } satisfies WatchedRepo; - }); + }); + } + + // Include watched repos missing from the available list — repos added by name, + // or ones beyond the pagination cap in a very large org. Only surface enabled + // or pinned ones so we don't resurrect stale, no-longer-accessible entries. + for (const w of watched) { + const key = `${w.platform}:${w.fullName}`; + if (seen.has(key) || (!w.enabled && !w.pinned)) continue; + seen.add(key); + list.push({ platform: w.platform, fullName: w.fullName, enabled: w.enabled, pinned: w.pinned ?? false }); + } + 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; @@ -26,6 +42,17 @@ function buildRepoList(available: AvailableRepo[], watched: WatchedRepo[]): Watc return list; } +// Accepts "owner/repo", a pasted web URL, or a ".git" clone URL and reduces it +// to the platform's path form (owner/repo, group/sub/project, workspace/repo). +function normalizeRepoInput(raw: string): string { + return raw + .trim() + .replace(/^https?:\/\/[^/]+\//i, '') // scheme + host + .replace(/^(www\.)?(github\.com|gitlab\.com|bitbucket\.org)\//i, '') // bare host + .replace(/\.git$/i, '') + .replace(/^\/+|\/+$/g, ''); // stray slashes +} + export default function Repos() { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); @@ -34,6 +61,11 @@ export default function Repos() { const [filter, setFilter] = useState(''); const [platformFilter, setPlatformFilter] = useState('all'); const [connectedPlatforms, setConnectedPlatforms] = useState>(new Set()); + const [showAdd, setShowAdd] = useState(false); + const [addPlatform, setAddPlatform] = useState('github'); + const [addValue, setAddValue] = useState(''); + const [adding, setAdding] = useState(false); + const [addError, setAddError] = useState(null); useEffect(() => { let cancelled = false; @@ -83,6 +115,53 @@ export default function Repos() { }; }, []); + // Keep the "add by name" platform pointed at a connected platform. + useEffect(() => { + const first = (['github', 'gitlab', 'bitbucket'] as Platform[]).find((p) => + connectedPlatforms.has(p), + ); + if (first && !connectedPlatforms.has(addPlatform)) setAddPlatform(first); + }, [connectedPlatforms, addPlatform]); + + async function handleAddRepo(e: React.FormEvent) { + e.preventDefault(); + const fullName = normalizeRepoInput(addValue); + if (!fullName.includes('/')) { + setAddError('Enter a repo as owner/name'); + return; + } + setAdding(true); + setAddError(null); + try { + const res = await chrome.runtime.sendMessage({ + type: 'VERIFY_REPO', + payload: { platform: addPlatform, fullName }, + }); + if (!res?.success) { + setAddError(res?.message ?? 'Could not add repo'); + return; + } + const canonical: string = res.fullName; + const key = `${addPlatform}:${canonical}`; + const exists = repos.some((r) => `${r.platform}:${r.fullName}` === key); + const updated = exists + ? repos.map((r) => (`${r.platform}:${r.fullName}` === key ? { ...r, enabled: true } : r)) + : [ + { platform: addPlatform, fullName: canonical, enabled: true, pinned: false } satisfies WatchedRepo, + ...repos, + ]; + setRepos(updated); + await saveWatchedRepos(updated); + chrome.runtime.sendMessage({ type: 'POLL_NOW' }); + setAddValue(''); + setShowAdd(false); + } catch { + setAddError('Could not reach the extension background'); + } finally { + setAdding(false); + } + } + async function handleToggle(fullName: string, platform: string) { const updated = repos.map((r) => r.fullName === fullName && r.platform === platform @@ -189,6 +268,84 @@ export default function Repos() { )} + + {connectedPlatforms.size > 0 && ( +
+ {!showAdd ? ( + + ) : ( +
+
+ {connectedPlatforms.size > 1 && ( + + )} + setAddValue(e.target.value)} + placeholder={ + addPlatform === 'bitbucket' + ? 'workspace/repo' + : addPlatform === 'gitlab' + ? 'group/project' + : 'owner/repo' + } + aria-label="Repository name to add" + className="flex-1 min-w-0 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-900 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 outline-none focus:border-radar-500" + /> + + +
+ {addError && ( +

+ {addError} +

+ )} +

+ For large orgs where a repo may not appear in the list above. +

+
+ )} +
+ )}
diff --git a/src/shared/api/bitbucket.ts b/src/shared/api/bitbucket.ts index fd728bb..92acd4d 100644 --- a/src/shared/api/bitbucket.ts +++ b/src/shared/api/bitbucket.ts @@ -84,6 +84,18 @@ export async function getAuthenticatedUser(token: string): Promise<{ uuid: strin return { uuid: user.uuid, nickname: user.nickname, display_name: user.display_name, avatar: user.links.avatar.href }; } +// Verify a single repo by its "workspace/repo" slug. Used by "add repo by name" +// so users can watch repos beyond the paginated list. Returns null if the repo +// doesn't exist or isn't accessible with this token. +export async function getRepository(token: string, fullName: string): Promise<{ full_name: string } | null> { + try { + return await bbFetch<{ full_name: string }>(`/repositories/${fullName}`, token); + } catch (err) { + if (err instanceof BitbucketAPIError && err.status === 404) return null; + throw err; + } +} + export async function getUserRepositories(token: string): Promise<{ full_name: string }[]> { // /2.0/workspaces was sunset on 2026-04-14 (CHANGE-2770). // Replacement: /2.0/user/workspaces returns workspace_access objects. diff --git a/src/shared/api/github.ts b/src/shared/api/github.ts index 0f83d58..f054db2 100644 --- a/src/shared/api/github.ts +++ b/src/shared/api/github.ts @@ -169,6 +169,18 @@ export async function getUserTeams(token: string): Promise { } } +// Verify a single repo by "owner/name". Used by "add repo by name" so users in +// very large orgs can watch repos that fall beyond the paginated list. Returns +// null if the repo doesn't exist or isn't accessible with this token. +export async function getRepo(token: string, fullName: string): Promise<{ full_name: string } | null> { + try { + return await ghFetch<{ full_name: string }>(`/repos/${fullName}`, token); + } catch (err) { + if (err instanceof GitHubAPIError && err.status === 404) return null; + throw err; + } +} + export async function getUserRepos(token: string): Promise<{ full_name: string }[]> { // Fetch personal repos const userRepos = await ghPaginate<{ full_name: string }>( diff --git a/src/shared/api/gitlab.ts b/src/shared/api/gitlab.ts index df8d0d7..98e7c58 100644 --- a/src/shared/api/gitlab.ts +++ b/src/shared/api/gitlab.ts @@ -98,6 +98,18 @@ export async function getAuthenticatedUser(token: string): Promise<{ username: s return glFetch('/user', token); } +// Verify a single project by its "group/project" path. Used by "add repo by +// name" so users can watch projects beyond the paginated list. Returns null if +// the project doesn't exist or isn't accessible with this token. +export async function getProject(token: string, path: string): Promise<{ path_with_namespace: string } | null> { + try { + return await glFetch<{ path_with_namespace: string }>(`/projects/${encodeURIComponent(path)}`, token); + } catch (err) { + if (err instanceof GitLabAPIError && err.status === 404) return null; + throw err; + } +} + export async function getUserProjects(token: string): Promise<{ path_with_namespace: string }[]> { // Paginate through all projects (GitLab returns x-next-page header) const allProjects: { path_with_namespace: string }[] = []; diff --git a/src/shared/types.ts b/src/shared/types.ts index 67248a2..f34daba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -174,4 +174,5 @@ export type Message = | { 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: 'FETCH_AVAILABLE_REPOS' }; + | { type: 'FETCH_AVAILABLE_REPOS' } + | { type: 'VERIFY_REPO'; payload: { platform: Platform; fullName: string } };