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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +140 to +167

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

Wrap getAccounts() in the same try/catch as the verification call.

If getAccounts() throws, this branch never calls sendResponse, leaving the popup's pending chrome.runtime.sendMessage promise unresolved and the "Adding…" UI state stuck.

🔧 Proposed fix
   } 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 {
+        const accounts = await getAccounts();
+        const account = accounts.find((a) => a.platform === platform);
+        if (!account) {
+          sendResponse({ success: false, message: `Not connected to ${platform}` });
+          return;
+        }
         let canonical: string | null = null;
         if (platform === 'github') {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} 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 === 'VERIFY_REPO') {
const { platform, fullName } = message.payload;
(async () => {
try {
const accounts = await getAccounts();
const account = accounts.find((a) => a.platform === platform);
if (!account) {
sendResponse({ success: false, message: `Not connected to ${platform}` });
return;
}
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
🤖 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 140 - 167, The VERIFY_REPO
branch in serviceWorker leaves the message unresolved if getAccounts() throws
before the existing try/catch. Move getAccounts() and the account lookup into
the same try/catch used for the repository verification so any failure still
calls sendResponse with an error. Keep the fix localized to the VERIFY_REPO
handler and preserve the async IIFE/channel-opening return behavior.

} else if (message.type === 'REFRESH_SETTINGS') {
setupPolling();
} else if (message.type === 'MERGE_PR') {
Expand Down
167 changes: 162 additions & 5 deletions src/popup/pages/Repos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
Expand All @@ -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<WatchedRepo[]>([]);
const [loading, setLoading] = useState(true);
Expand All @@ -34,6 +61,11 @@ export default function Repos() {
const [filter, setFilter] = useState('');
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all');
const [connectedPlatforms, setConnectedPlatforms] = useState<Set<Platform>>(new Set());
const [showAdd, setShowAdd] = useState(false);
const [addPlatform, setAddPlatform] = useState<Platform>('github');
const [addValue, setAddValue] = useState('');
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -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,
Comment on lines +147 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve existing watched repos when adding before cache loads

When the available-repos cache is empty, the page does not initialize repos from the stored watched list before awaiting FETCH_AVAILABLE_REPOS, but the add form is still visible. If a user adds a repo by name during that first or slow refresh, this updated array is built from the current repos state, which can be empty/stale, and saveWatchedRepos(updated) then replaces all stored watched repos with only the new entry, so existing selections stop being polled. Merge with getWatchedRepos() at save time or disable the form until initialization completes.

Useful? React with 👍 / 👎.

];
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);
}
}
Comment on lines +126 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stale repos closure can lose a concurrent toggle while verification is in flight.

handleAddRepo computes updated from the repos snapshot captured when the function was created, after awaiting a full network round trip (VERIFY_REPO). If the user toggles/pins another repo (or hits "Select all") while "Adding…" is pending, that write is silently reverted once handleAddRepo's saveWatchedRepos(updated) lands, since updated doesn't include the intervening change. handleToggle/handleTogglePin/handleSelectAll have the same read-then-write pattern, but their await window (a fast chrome.storage write) is much shorter than this new network-bound verification call.

🔧 Proposed fix using functional state update
       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);
+      let updated: WatchedRepo[] = repos;
+      setRepos((prev) => {
+        const exists = prev.some((r) => `${r.platform}:${r.fullName}` === key);
+        updated = exists
+          ? prev.map((r) => (`${r.platform}:${r.fullName}` === key ? { ...r, enabled: true } : r))
+          : [
+              { platform: addPlatform, fullName: canonical, enabled: true, pinned: false } satisfies WatchedRepo,
+              ...prev,
+            ];
+        return updated;
+      });
       await saveWatchedRepos(updated);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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}`;
let updated: WatchedRepo[] = repos;
setRepos((prev) => {
const exists = prev.some((r) => `${r.platform}:${r.fullName}` === key);
updated = exists
? prev.map((r) => (`${r.platform}:${r.fullName}` === key ? { ...r, enabled: true } : r))
: [
{ platform: addPlatform, fullName: canonical, enabled: true, pinned: false } satisfies WatchedRepo,
...prev,
];
return updated;
});
await saveWatchedRepos(updated);
chrome.runtime.sendMessage({ type: 'POLL_NOW' });
setAddValue('');
setShowAdd(false);
} catch {
setAddError('Could not reach the extension background');
} finally {
setAdding(false);
}
}
🤖 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/popup/pages/Repos.tsx` around lines 126 - 163, handleAddRepo in Repos.tsx
is using a stale repos snapshot after an awaited VERIFY_REPO call, which can
overwrite concurrent changes like toggles, pin updates, or Select all. Update
the add flow to derive the next repos state from the latest state at write time,
using a functional state update or equivalent merge logic around
setRepos/saveWatchedRepos, so the new repo is added without clobbering
intervening changes. If needed, review handleToggle, handleTogglePin, and
handleSelectAll for the same read-then-write pattern and make them consistent.


async function handleToggle(fullName: string, platform: string) {
const updated = repos.map((r) =>
r.fullName === fullName && r.platform === platform
Expand Down Expand Up @@ -189,6 +268,84 @@ export default function Repos() {
</button>
)}
</div>

{connectedPlatforms.size > 0 && (
<div className="mt-2">
{!showAdd ? (
<button
onClick={() => {
setShowAdd(true);
setAddError(null);
}}
className="text-[11px] text-radar-400 hover:underline"
>
+ Add a repo by name
</button>
Comment on lines +274 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add aria-expanded to the add-by-name toggle button.

The button that reveals the form doesn't expose its expanded/collapsed state to assistive tech.

Based on learnings, "Keep accessibility requirements broad: ARIA labels/roles, live regions, expanded state, hidden decorative icons, and labeled form inputs."

🔧 Proposed fix
               <button
                 onClick={() => {
                   setShowAdd(true);
                   setAddError(null);
                 }}
+                aria-expanded={showAdd}
                 className="text-[11px] text-radar-400 hover:underline"
               >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{!showAdd ? (
<button
onClick={() => {
setShowAdd(true);
setAddError(null);
}}
className="text-[11px] text-radar-400 hover:underline"
>
+ Add a repo by name
</button>
{!showAdd ? (
<button
onClick={() => {
setShowAdd(true);
setAddError(null);
}}
aria-expanded={showAdd}
className="text-[11px] text-radar-400 hover:underline"
>
Add a repo by name
</button>
🤖 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/popup/pages/Repos.tsx` around lines 274 - 283, The add-by-name toggle
button in Repos should expose its open/closed state to assistive tech. Update
the button in the showAdd toggle block to include an aria-expanded attribute
that reflects the showAdd state, so screen readers can tell whether the add form
is currently visible. Keep the change localized to the button that calls
setShowAdd and clears addError.

Source: Learnings

) : (
<form onSubmit={handleAddRepo} className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
{connectedPlatforms.size > 1 && (
<select
value={addPlatform}
onChange={(e) => setAddPlatform(e.target.value as Platform)}
aria-label="Platform for the repo to add"
className="bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md px-1.5 py-1.5 text-xs text-gray-900 dark:text-gray-200 outline-none focus:border-radar-500"
>
{(['github', 'gitlab', 'bitbucket'] as Platform[])
.filter((p) => connectedPlatforms.has(p))
.map((p) => (
<option key={p} value={p}>
{p === 'github' ? 'GitHub' : p === 'gitlab' ? 'GitLab' : 'Bitbucket'}
</option>
))}
</select>
)}
<input
type="text"
autoFocus
value={addValue}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={adding || !addValue.trim()}
className="flex-shrink-0 text-[11px] px-2.5 py-1.5 rounded-md bg-radar-600 text-white disabled:opacity-50 hover:bg-radar-500 transition-colors"
>
{adding ? 'Adding…' : 'Add'}
</button>
<button
type="button"
onClick={() => {
setShowAdd(false);
setAddValue('');
setAddError(null);
}}
className="flex-shrink-0 text-[11px] px-1.5 py-1.5 text-gray-500 hover:text-gray-400"
>
Cancel
</button>
</div>
{addError && (
<p className="text-[11px] text-red-500 dark:text-red-400" role="alert">
{addError}
</p>
)}
<p className="text-[11px] text-gray-500">
For large orgs where a repo may not appear in the list above.
</p>
</form>
)}
</div>
)}
</div>

<div className="flex-1 overflow-y-auto">
Expand Down
12 changes: 12 additions & 0 deletions src/shared/api/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/shared/api/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ export async function getUserTeams(token: string): Promise<GHUserTeam[]> {
}
}

// 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 }>(
Expand Down
12 changes: 12 additions & 0 deletions src/shared/api/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [];
Expand Down
3 changes: 2 additions & 1 deletion src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Loading