Filter private repos by GitHub App installation access#139
Conversation
Build a cached installation access index that tracks which repos/owners the GitHub App is installed for. Private repos are only shown if the owning account has an "all" installation or the specific repo is in the "selected" set. Public repos always pass through. - Add `installationAccess` signal key and cache policy (30 min stale) - Webhook events (installation, installation_repositories, github_app_authorization) invalidate the index immediately - `getInstallationAccessIndex` fetches installations and paginates selected repos, cached via getOrRevalidateGitHubResource - `isRepoVisibleWithInstallationAccess` filter utility (fail-open when no app is configured) - Apply filter in `getUserRepos` (parallel fetch with repo list) - Expose `getInstallationAccess` server function for other consumers - 7 new tests covering all filter scenarios
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
diffkit | 318a19b | Apr 16 2026, 09:44 PM |
Add isPrivate to RepositoryRef and all GraphQL repository fragments so the installation access filter can be applied everywhere, not just getUserRepos. - Add isPrivate to RepositoryRef type and GitHubGraphQLRepositoryRef - Update buildRepositoryRef, parseRepositoryRef, mapGraphQLRepositoryRef - Add isPrivate to GraphQL fragments for pull search, issue search, pull detail, and issue detail queries - Add generic filterItemsByInstallationAccess helper and specialized filterMyPullsResult / filterMyIssuesResult wrappers - Apply filter in getMyPulls, getMyIssues (parallel fetch with result) - Apply filter in searchCommandPaletteGitHub (parallel with search) - Apply filter in getNotifications (parallel with notification fetch) - Apply filter in getUserPinnedRepos
Add debug() calls so the access index building and filtering behavior is fully observable in local dev: - Log when fetching the index (cache miss vs resolved from cache) - Log each installation's access mode (all/selected/suspended) - Log the full built index (owners, repos, counts) - Log whenever items are filtered out, with repo names and counts (getUserRepos, getMyPulls, getMyIssues, command palette, notifications, pinned repos)
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughAdds an installation-access index and visibility gating, propagates repository privacy ( Changes
Sequence DiagramsequenceDiagram
participant Client
participant Server
participant Cache
participant GraphQL
participant AccessIdx
Client->>Server: Request repos/pulls/notifications
par Parallel fetches
Server->>Cache: read installationAccess index
alt index cached
Cache-->>Server: return index
else not cached
Server->>GraphQL: fetch app installations (paginated)
GraphQL-->>Server: installations
Server->>AccessIdx: build normalized index
AccessIdx-->>Server: index
Server->>Cache: store index
end
Server->>GraphQL: fetch repos/pulls/issues (include isPrivate)
GraphQL-->>Server: items with repo refs
end
loop per item
Server->>AccessIdx: isRepoVisibleWithInstallationAccess(owner, repo, isPrivate)
AccessIdx-->>Server: allow|deny
end
Server-->>Client: filtered results
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/dashboard/src/lib/github.functions.ts (1)
671-705:⚠️ Potential issue | 🔴 CriticalDon't default unknown repo privacy to public.
isPrivate = falseturns “privacy not provided” into “public repo”.mapPullSearchItems()/mapIssueSearchItems()still callparseRepositoryRef(item.repository_url)without the flag, so REST-search results from private repos can bypassisRepoVisibleWithInstallationAccess()and still appear in filtered surfaces like command palette search.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/lib/github.functions.ts` around lines 671 - 705, The functions buildRepositoryRef and parseRepositoryRef currently default isPrivate to false which turns "unknown" into public; change both signatures so isPrivate is optional (isPrivate?: boolean | null) and stop defaulting to false, have buildRepositoryRef preserve and store the passed-through undefined/null when privacy is unknown, and have parseRepositoryRef call buildRepositoryRef without forcing false (i.e., pass through the optional isPrivate), then update callers like mapPullSearchItems and mapIssueSearchItems to supply an explicit boolean when they know repo privacy so unknown privacy remains distinct and can be filtered by isRepoVisibleWithInstallationAccess().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/dashboard/src/lib/github.functions.ts`:
- Around line 1613-1615: The code assumes getGitHubAppUserInstallations(...)
returns all installations but that helper only fetches one page (per_page:100);
update the logic so all installations are fetched before building the index:
either modify getGitHubAppUserInstallations to paginate (use octokit.paginate or
loop GET /user/installations with page increments until no more items) or
replace the single-call with a paginated fetch at the call site, then use the
full installations array (the installations variable) and installationsAvailable
flag when constructing the owner/repo index to avoid missing installations
beyond the first 100.
- Around line 1616-1629: The code currently treats any failure in computing
installationsAvailable as if "app-user token is unavailable" and returns {
available: false }, which causes isRepoVisibleWithInstallationAccess() to
allow-all private repos; update the branch around installationsAvailable so you
only return { available: false, ... } when you have a definitive signal that the
app-user token is not configured (e.g., add/use an explicit predicate like
isAppUserTokenConfigured() or a specific sentinel value on
installationsAvailable), and for any other runtime/auth/cache errors propagate
an error or return a failing-kind response (do not set available:false). Modify
the logic that returns the syntheticGitHubResponseMetadata() at the
installationsAvailable check and the similar block at the other occurrence
(around lines 1741-1743) so transient errors are handled conservatively (throw
or return kind:"error") while only the explicit "no app-user token" case returns
available:false.
---
Outside diff comments:
In `@apps/dashboard/src/lib/github.functions.ts`:
- Around line 671-705: The functions buildRepositoryRef and parseRepositoryRef
currently default isPrivate to false which turns "unknown" into public; change
both signatures so isPrivate is optional (isPrivate?: boolean | null) and stop
defaulting to false, have buildRepositoryRef preserve and store the
passed-through undefined/null when privacy is unknown, and have
parseRepositoryRef call buildRepositoryRef without forcing false (i.e., pass
through the optional isPrivate), then update callers like mapPullSearchItems and
mapIssueSearchItems to supply an explicit boolean when they know repo privacy so
unknown privacy remains distinct and can be filtered by
isRepoVisibleWithInstallationAccess().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: b4783c6d-3c6c-439f-bf4d-2198b4f0ebad
📒 Files selected for processing (6)
apps/dashboard/src/lib/github-access.test.tsapps/dashboard/src/lib/github-access.tsapps/dashboard/src/lib/github-cache-policy.tsapps/dashboard/src/lib/github-revalidation.tsapps/dashboard/src/lib/github.functions.tsapps/dashboard/src/lib/github.types.ts
Add visibilitychange listener to /setup page and access dialog that busts the server-side installation access cache and invalidates all GitHub queries when the user returns from configuring permissions.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (2)
apps/dashboard/src/lib/github.functions.ts (2)
1614-1615:⚠️ Potential issue | 🟠 MajorPaginate app installations before building this index.
getInstallationAccessIndex()still depends ongetGitHubAppUserInstallations(), and that helper only does oneGET /user/installationspage (per_page: 100). Users with more than 100 installations will miss later owners here, so valid private repos get filtered out incorrectly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/lib/github.functions.ts` around lines 1614 - 1615, getInstallationAccessIndex is using getGitHubAppUserInstallations which only fetches one page (per_page:100), causing owners beyond 100 installations to be missed; update the logic to paginate and collect all installations before building the index—either modify getGitHubAppUserInstallations to use Octokit's pagination helper (octokit.paginate or loop with page increments until no results) or add pagination in getInstallationAccessIndex itself, ensuring you accumulate all installation items from every page before mapping/filtering.
1617-1630:⚠️ Potential issue | 🔴 CriticalOnly fail open when app-user auth is definitively absent.
getGitHubAppUserInstallations()currently collapses generic GitHub/auth/cache failures intoinstallationsAvailable: false, and these branches translate that intoavailable: false. BecauseisRepoVisibleWithInstallationAccess()allows all private repos when the index is unavailable, a transient failure here exposes every private repo instead of failing safely.Also applies to: 1742-1744
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/dashboard/src/lib/github.functions.ts` around lines 1617 - 1630, Change the logic so we only "fail open" (return available: false and allow isRepoVisibleWithInstallationAccess to assume index-unavailable) when app-user auth is definitively absent, not for generic/transient failures: update getGitHubAppUserInstallations() to surface the failure type (e.g., return { installationsAvailable, authMissing } or throw on transient errors) and in this branch only return the synthetic response when authMissing is true; for other failures propagate/throw or return an explicit transient-error result so callers (including isRepoVisibleWithInstallationAccess) can fail-closed instead of exposing private repos.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/dashboard/src/lib/github.functions.ts`:
- Around line 4842-4849: The command-palette filtering treats all search hits as
public because mapPullSearchItems and mapIssueSearchItems call
parseRepositoryRef(item.repository_url) which defaults RepositoryRef.isPrivate =
false; update those mapping functions to derive and pass the real privacy flag
(e.g., use item.repository?.private or the search hit's private field) into
parseRepositoryRef or set RepositoryRef.isPrivate accordingly so
filterItemsByInstallationAccess sees the true repository.isPrivate value.
- Around line 1663-1671: The code calls
listInstallationReposForAuthenticatedUser using context.octokit (an OAuth token)
which requires a GitHub App user access token; replace context.octokit with an
app-user client obtained via
getGitHubAppUserClientByUserId(context.session.user.id) and use that client to
call rest.apps.listInstallationReposForAuthenticatedUser({ installation_id:
installation.id, page, per_page: 100 }) inside listPaginatedGitHubItems (ensure
you await/get the client before the paginated call and handle the case the
app-user client may be null/error).
In `@apps/dashboard/src/lib/use-refresh-on-return.ts`:
- Around line 23-31: The refresh function currently awaits
refreshInstallationAccess() and then invalidates client queries and router, but
if refreshInstallationAccess() rejects those invalidations never run; wrap the
await call in a try/finally (or try/catch/finally) inside the useCallback
refresh function so that queryClient.invalidateQueries({ queryKey:
githubQueryKeys.all }), queryClient.invalidateQueries({ queryKey:
["github-app-access-state"] }) and router.invalidate() are executed in the
finally block (keeping the current await/throw behavior as needed) to ensure
local state is always invalidated even when refreshInstallationAccess() fails.
---
Duplicate comments:
In `@apps/dashboard/src/lib/github.functions.ts`:
- Around line 1614-1615: getInstallationAccessIndex is using
getGitHubAppUserInstallations which only fetches one page (per_page:100),
causing owners beyond 100 installations to be missed; update the logic to
paginate and collect all installations before building the index—either modify
getGitHubAppUserInstallations to use Octokit's pagination helper
(octokit.paginate or loop with page increments until no results) or add
pagination in getInstallationAccessIndex itself, ensuring you accumulate all
installation items from every page before mapping/filtering.
- Around line 1617-1630: Change the logic so we only "fail open" (return
available: false and allow isRepoVisibleWithInstallationAccess to assume
index-unavailable) when app-user auth is definitively absent, not for
generic/transient failures: update getGitHubAppUserInstallations() to surface
the failure type (e.g., return { installationsAvailable, authMissing } or throw
on transient errors) and in this branch only return the synthetic response when
authMissing is true; for other failures propagate/throw or return an explicit
transient-error result so callers (including
isRepoVisibleWithInstallationAccess) can fail-closed instead of exposing private
repos.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 2768f7c6-0ff7-42c5-8a70-e18f86973c28
📒 Files selected for processing (4)
apps/dashboard/src/components/layouts/github-access-dialog.tsxapps/dashboard/src/lib/github.functions.tsapps/dashboard/src/lib/use-refresh-on-return.tsapps/dashboard/src/routes/setup.tsx
| pulls: filterItemsByInstallationAccess( | ||
| mapPullSearchItems(pullItems), | ||
| accessIndex, | ||
| ), | ||
| issues: filterItemsByInstallationAccess( | ||
| mapIssueSearchItems(issueItems), | ||
| accessIndex, | ||
| ), |
There was a problem hiding this comment.
Command-palette filtering is currently a no-op for private repos.
filterItemsByInstallationAccess(...) relies on repository.isPrivate, but mapPullSearchItems() / mapIssueSearchItems() build RepositoryRefs from parseRepositoryRef(item.repository_url) with the default isPrivate = false. Every REST search hit is therefore treated as public here, so private repos from owners without installation access still pass through.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/dashboard/src/lib/github.functions.ts` around lines 4842 - 4849, The
command-palette filtering treats all search hits as public because
mapPullSearchItems and mapIssueSearchItems call
parseRepositoryRef(item.repository_url) which defaults RepositoryRef.isPrivate =
false; update those mapping functions to derive and pass the real privacy flag
(e.g., use item.repository?.private or the search hit's private field) into
parseRepositoryRef or set RepositoryRef.isPrivate accordingly so
filterItemsByInstallationAccess sees the true repository.isPrivate value.
- Paginate GET /user/installations (handles >100 installations) - Let transient API errors propagate instead of silently failing open - Use app-user client (not OAuth) for listInstallationReposForAuthenticatedUser - Default unknown repo visibility to null instead of false (public), so REST search results are checked against the access index - Wrap refreshInstallationAccess in try/finally so client-side cache invalidation runs even if the server call fails
Summary
getUserReposinstallation,installation_repositories, andgithub_app_authorizationeventsgetInstallationAccessas a public server function for other consumersHow it works
getInstallationAccessIndex()fetches all installations via the app-user token. For "all" installations, the owner login is added to an allow set. For "selected" installations, it paginateslistInstallationReposForAuthenticatedUserto get the exact repo list.isRepoVisibleWithInstallationAccess()applies the filter: public repos always pass, private repos pass only if the owner has "all" access or the specific repo is in the "selected" set. Fails open when no app is configured.getUserReposfetches the access index in parallel with the repo list and filters the results.Changes
github-access.tsGitHubInstallationAccessIndextype andisRepoVisibleWithInstallationAccessfiltergithub.functions.tsgetInstallationAccessIndex(cached builder),getInstallationAccess(server fn), filter wired intogetUserReposgithub-revalidation.tsinstallationAccesssignal key + webhook generation for installation eventsgithub-cache-policy.tsinstallationAccesscache policy (30 min stale / 24 hr gc)github-access.test.tsFollow-up
PullSummary/IssueSummarydon't carryisPrivatetoday. Adding it toRepositoryRefand GraphQL fragments would enable filtering in my-pulls, my-issues, and command palette search.privateflag, easy to wire up.Test plan
Summary by CodeRabbit
New Features
Chores
Tests