Skip to content

Filter private repos by GitHub App installation access#139

Merged
stylessh merged 5 commits intomainfrom
stylessh/jolly-payne
Apr 16, 2026
Merged

Filter private repos by GitHub App installation access#139
stylessh merged 5 commits intomainfrom
stylessh/jolly-payne

Conversation

@stylessh
Copy link
Copy Markdown
Owner

@stylessh stylessh commented Apr 16, 2026

Summary

  • Build a cached installation access index that tracks which repos/owners the GitHub App is installed for, then filter private repos against it in getUserRepos
  • Aggressively cache the index (30-min stale time, D1 + KV split cache) and invalidate instantly via webhook on installation, installation_repositories, and github_app_authorization events
  • Expose getInstallationAccess as a public server function for other consumers

How it works

  1. 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 paginates listInstallationReposForAuthenticatedUser to get the exact repo list.
  2. 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.
  3. getUserRepos fetches the access index in parallel with the repo list and filters the results.

Changes

File What
github-access.ts New GitHubInstallationAccessIndex type and isRepoVisibleWithInstallationAccess filter
github.functions.ts getInstallationAccessIndex (cached builder), getInstallationAccess (server fn), filter wired into getUserRepos
github-revalidation.ts New installationAccess signal key + webhook generation for installation events
github-cache-policy.ts installationAccess cache policy (30 min stale / 24 hr gc)
github-access.test.ts 7 new tests covering all filter scenarios

Follow-up

  • Search results filteringPullSummary/IssueSummary don't carry isPrivate today. Adding it to RepositoryRef and GraphQL fragments would enable filtering in my-pulls, my-issues, and command palette search.
  • Notifications / pinned repos — already have private flag, easy to wire up.

Test plan

  • All 57 existing + new tests pass
  • Biome check passes (only pre-existing warning)
  • Verify with a real GitHub App installation that has "selected" repos
  • Verify webhook invalidation works end-to-end

Summary by CodeRabbit

  • New Features

    • Repository visibility filtering: private repos are now shown/hidden based on installation access across lists, search, notifications, pins, and related views.
    • Automatic refresh on return: pages and the GitHub access dialog refresh when you return to the app to reflect updated installation access.
  • Chores

    • Added targeted revalidation and caching for installation-access state.
  • Tests

    • Added unit tests validating visibility rules and case-insensitive matching.

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
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 16, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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)
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 16, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds an installation-access index and visibility gating, propagates repository privacy (isPrivate) through types and GraphQL, caches and revalidates installation-access, applies visibility filtering across multiple server endpoints, and adds a hook to refresh installation access when returning to the app.

Changes

Cohort / File(s) Summary
Installation Access Core
apps/dashboard/src/lib/github-access.ts, apps/dashboard/src/lib/github-access.test.ts
Add GitHubInstallationAccessIndex, emptyInstallationAccessIndex(), and isRepoVisibleWithInstallationAccess(); unit tests for public/private, case-insensitive matching, selected owners/repos, and fail-open behavior.
Cache & Revalidation
apps/dashboard/src/lib/github-cache-policy.ts, apps/dashboard/src/lib/github-revalidation.ts
Add installationAccess cache policy and installationAccess revalidation signal; installation-related webhooks short-circuit to this signal.
Repository Privacy Propagation
apps/dashboard/src/lib/github.types.ts, apps/dashboard/src/lib/github.functions.ts
Add `isPrivate: boolean
Server Integration & Endpoints
apps/dashboard/src/lib/github.functions.ts
Add cached getInstallationAccessIndex() pipeline, getInstallationAccess (GET), refreshInstallationAccess (POST); enforce visibility filtering via isRepoVisibleWithInstallationAccess in getUserRepos, searchCommandPaletteGitHub, getMyPulls, getMyIssues, getUserPinnedRepos, and getNotifications.
Client Refresh Hook & UI Wiring
apps/dashboard/src/lib/use-refresh-on-return.ts, apps/dashboard/src/components/layouts/github-access-dialog.tsx, apps/dashboard/src/routes/setup.tsx
Add useRefreshOnReturn hook that refreshes installation access and invalidates queries on tab visibility return; used in GitHubAccessDialog and SetupPage.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the primary change: filtering private repositories by GitHub App installation access.
Description check ✅ Passed The description covers all required template sections (Summary, Changes, Test Plan) with detailed explanations of the implementation approach, affected files, and testing status.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch stylessh/jolly-payne

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 | 🔴 Critical

Don't default unknown repo privacy to public.

isPrivate = false turns “privacy not provided” into “public repo”. mapPullSearchItems() / mapIssueSearchItems() still call parseRepositoryRef(item.repository_url) without the flag, so REST-search results from private repos can bypass isRepoVisibleWithInstallationAccess() 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

📥 Commits

Reviewing files that changed from the base of the PR and between bee56dd and 7848eb4.

📒 Files selected for processing (6)
  • apps/dashboard/src/lib/github-access.test.ts
  • apps/dashboard/src/lib/github-access.ts
  • apps/dashboard/src/lib/github-cache-policy.ts
  • apps/dashboard/src/lib/github-revalidation.ts
  • apps/dashboard/src/lib/github.functions.ts
  • apps/dashboard/src/lib/github.types.ts

Comment thread apps/dashboard/src/lib/github.functions.ts Outdated
Comment thread apps/dashboard/src/lib/github.functions.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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
apps/dashboard/src/lib/github.functions.ts (2)

1614-1615: ⚠️ Potential issue | 🟠 Major

Paginate app installations before building this index.

getInstallationAccessIndex() still depends on getGitHubAppUserInstallations(), and that helper only does one GET /user/installations page (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 | 🔴 Critical

Only fail open when app-user auth is definitively absent.

getGitHubAppUserInstallations() currently collapses generic GitHub/auth/cache failures into installationsAvailable: false, and these branches translate that into available: false. Because isRepoVisibleWithInstallationAccess() 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7848eb4 and be81af3.

📒 Files selected for processing (4)
  • apps/dashboard/src/components/layouts/github-access-dialog.tsx
  • apps/dashboard/src/lib/github.functions.ts
  • apps/dashboard/src/lib/use-refresh-on-return.ts
  • apps/dashboard/src/routes/setup.tsx

Comment thread apps/dashboard/src/lib/github.functions.ts
Comment on lines +4842 to +4849
pulls: filterItemsByInstallationAccess(
mapPullSearchItems(pullItems),
accessIndex,
),
issues: filterItemsByInstallationAccess(
mapIssueSearchItems(issueItems),
accessIndex,
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment thread apps/dashboard/src/lib/use-refresh-on-return.ts Outdated
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant