fix(ui): simplify GitHub repository access controls (#13047)

## Thinking Path

> - Paperclip helps people manage AI agents and their connected apps.
> - GitHub permissions show which repositories an account can access.
> - The page adds an account dropdown and search above the repository
list.
> - These controls add clutter to a view meant to show the full access
list.
> - This change removes both controls and their filtering state.
> - People see all repositories directly, with the existing scrolling
and access controls.

## Linked Issues or Issue Description

Refs #12998.

**Current behavior**

GitHub permissions show an “All accounts” dropdown and a “Search
repositories” input above the repository list.

**Proposed behavior**

Show the full repository list directly. Keep the scroll limit,
repository links, private-repository icons, refresh button, and GitHub
configuration links.

**Reason and benefit**

Remove unnecessary controls from the access summary. Users manage
repository permissions on GitHub.

## What Changed

- Remove the account dropdown, search input, filtering state, and unused
imports.
- Rename the GitHub configuration button to “Add More Repos on GitHub”.
- Render every returned repository and simplify the empty-list message.
- Update the existing multi-account test and permissions documentation.

## Verification

- All 55 AppDetail tests passed.
- `pnpm check:token-gates` passed.
- Full `pnpm build` and `pnpm -r typecheck` passed. All 31 CI checks
passed on `cb9b8bb14a880d926393c62821265fbd2d8c5d31`. Storybook visual
regression was correctly skipped.
- Fresh Greptile review on the same commit: 5/5, with no unresolved
findings.

## Risks

Users can no longer narrow this list by account or search text. The list
still scrolls and shows each repository’s full owner/name. This change
does not alter GitHub permissions or credential selection.

## Model Used

OpenAI GPT-6 through Codex, with code editing and shell verification
tools. The exact model variant and context-window size are not exposed
in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 12:28:23 -05:00 committed by GitHub
parent be6bb768b1
commit db85bf4b7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 20 additions and 71 deletions

View File

@ -28,6 +28,8 @@ An explicit dedicated-agent grant overrides personal selection. Revoked, disable
Connection setup and permissions display: “This agent uses this GitHub account for everyone's work, instead of the person giving instructions.”
The GitHub permissions page shows repositories across all connected accounts in one scrollable list. It has no account filter or repository search. Repository icons, private-repository indicators, refresh, and GitHub configuration links remain available. The “Add More Repos on GitHub” button opens GitHubs app installation and repository-access setup.
Multiple eligible connections for the same GitHub account are treated as one
identity, using GitHub's stable account ID rather than its login. The resolver
selects an available grant, preferring the newest authorization with a stable

View File

@ -1508,19 +1508,19 @@ describe("AppDetail", () => {
await act(async () => { findButton("Load GitHub configuration")!.click(); });
await flushReact();
expect(checkConnectionHealthMock).toHaveBeenCalledWith("conn-1");
expect(container.querySelector('a[href="https://github.com/apps/paperclip-staging/installations/new"]')?.textContent).toBe("Configure on GitHub");
expect(container.querySelector('a[href="https://github.com/apps/paperclip-staging/installations/new"]')?.textContent).toBe("Add More Repos on GitHub");
expect(findButton("Load GitHub configuration")).toBeUndefined();
});
it("filters the combined GitHub repository list by owner and search without changing access", async () => {
it.each([false, true])("shows repositories across accounts without filter controls (empty: %s)", async (empty) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
repositoryCount: 3,
repositoryCount: empty ? 0 : 3,
installationOwnerLogins: ["paperclipai", "dottabot", "empty-org"],
repositories: [
repositories: empty ? [] : [
{ id: "1", fullName: "paperclipai/first", installationId: "456" },
{ id: "2", fullName: "paperclipai/second", installationId: "456" },
{ id: "3", fullName: "dottabot/first", installationId: "789" },
@ -1530,31 +1530,14 @@ describe("AppDetail", () => {
});
await renderAppDetail();
const repositoryNames = () => [...container.querySelectorAll('ul[aria-label="Accessible GitHub repositories"] a')].map((link) => link.textContent);
const selectOwner = async (label: string) => {
await act(async () => {
container.querySelector('[role="combobox"][aria-label="Filter repositories by account or organization"]')!
.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
});
const option = [...document.querySelectorAll('[role="option"]')].find((item) => item.textContent === label);
expect(option).toBeTruthy();
await act(async () => {
option!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
};
expect(repositoryNames()).toEqual(["paperclipai/first", "paperclipai/second", "dottabot/first"]);
await selectOwner("paperclipai");
expect(repositoryNames()).toEqual(["paperclipai/first", "paperclipai/second"]);
const search = container.querySelector<HTMLInputElement>('input[aria-label="Search GitHub repositories"]')!;
await act(async () => { setInputValue(search, "FIRST"); });
expect(repositoryNames()).toEqual(["paperclipai/first"]);
await selectOwner("All accounts");
expect(repositoryNames()).toEqual(["paperclipai/first", "dottabot/first"]);
await act(async () => { setInputValue(search, "missing"); });
expect(container.textContent).toContain("No repositories match your search.");
await act(async () => { setInputValue(search, ""); });
await selectOwner("empty-org");
expect(container.textContent).toContain("No accessible repositories for this account or organization.");
expect(container.querySelector('a[href="https://github.com/apps/paperclip-test/installations/new"]')?.textContent).toBe("Configure on GitHub");
expect(repositoryNames()).toEqual(empty ? [] : ["paperclipai/first", "paperclipai/second", "dottabot/first"]);
if (empty) {
expect(container.querySelector('p[role="status"]')?.textContent?.trim()).toBe("No accessible repositories.");
expect(container.textContent).not.toContain("Refresh access to load the current repository list.");
}
expect(container.querySelector('[aria-label="Filter repositories by account or organization"]')).toBeNull();
expect(container.querySelector('input[aria-label="Search GitHub repositories"]')).toBeNull();
expect(container.querySelector('a[href="https://github.com/apps/paperclip-test/installations/new"]')?.textContent).toBe("Add More Repos on GitHub");
expect(updateConnectionMock).not.toHaveBeenCalled();
});
@ -1581,7 +1564,7 @@ describe("AppDetail", () => {
expect(container.querySelector('a[href="https://github.com/paperclipai/test-repo"]')?.textContent).toBe("paperclipai/test-repo");
expect(container.querySelector(
'a[href="https://github.com/apps/paperclip-test/installations/new"]',
)?.textContent).toBe("Configure on GitHub");
)?.textContent).toBe("Add More Repos on GitHub");
expect(container.querySelector('button[aria-label="Refresh access"]')).toBeTruthy();
expect(container.textContent).not.toContain("Installation");
expect(container.textContent).not.toContain("Token continuity");

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Building2, Loader2, Lock, RefreshCw, Search, TriangleAlert, UserRound } from "lucide-react";
import { Building2, Loader2, Lock, RefreshCw, TriangleAlert, UserRound } from "lucide-react";
import type {
ConnectionAudienceMember,
ConnectionGrant,
@ -9,8 +9,6 @@ import type {
import { Button } from "@/components/ui/button";
import { Identity } from "@/components/Identity";
import { GithubIcon } from "@/components/icons/github-icon";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { InlineBanner } from "@/components/InlineBanner";
import { MemberMultiSelect } from "@/components/MemberMultiSelect";
@ -295,24 +293,8 @@ function GitHubConnectionSummary({
onRefreshAccess?: () => void;
refreshPending: boolean;
}) {
const [repositoryOwner, setRepositoryOwner] = useState("*");
const [repositorySearch, setRepositorySearch] = useState("");
useEffect(() => {
setRepositoryOwner("*");
setRepositorySearch("");
}, [grant.id]);
const github = grant.providerTenant?.github;
if (!github) return null;
const owners = [...new Set([
...(github.installationOwnerLogins ?? []),
...(github.repositories ?? []).map((repository) => repository.fullName.split("/")[0]),
])].sort((a, b) => a.localeCompare(b));
const selectedOwner = owners.includes(repositoryOwner) ? repositoryOwner : "*";
const search = repositorySearch.trim().toLowerCase();
const visibleRepositories = github.repositories?.filter((repository) => (
(selectedOwner === "*" || repository.fullName.split("/")[0] === selectedOwner)
&& repository.fullName.toLowerCase().includes(search)
));
const configurationUrl = github.appSlug
? `https://github.com/apps/${encodeURIComponent(github.appSlug)}/installations/new`
: /^https:\/\/github\.com\/apps\/[a-z0-9-]+\/installations\/new$/.test(github.installationUrl ?? "")
@ -361,7 +343,7 @@ function GitHubConnectionSummary({
) : null}
{configurationUrl ? (
<Button asChild size="sm" variant="outline">
<a href={configurationUrl} target="_blank" rel="noreferrer">Configure on GitHub</a>
<a href={configurationUrl} target="_blank" rel="noreferrer">Add More Repos on GitHub</a>
</Button>
) : onRefreshAccess ? (
<Button size="sm" variant="outline" disabled={refreshPending} onClick={onRefreshAccess}>
@ -371,27 +353,9 @@ function GitHubConnectionSummary({
) : null}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Select value={selectedOwner} onValueChange={setRepositoryOwner}>
<SelectTrigger aria-label="Filter repositories by account or organization" className="w-full">
<span className="flex min-w-0 items-center gap-2">
<GithubIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
<SelectValue />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="*">All accounts</SelectItem>
{owners.map((owner) => <SelectItem key={owner} value={owner}>{owner}</SelectItem>)}
</SelectContent>
</Select>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<Input aria-label="Search GitHub repositories" placeholder="Search repositories" className="pl-9" value={repositorySearch} onChange={(event) => setRepositorySearch(event.target.value)} />
</div>
</div>
{github.repositories ? (
visibleRepositories?.length ? <ul aria-label="Accessible GitHub repositories" tabIndex={0} className="max-h-(--sz-github-repository-list) space-y-2 overflow-y-auto text-sm">
{visibleRepositories.map((repository) => (
github.repositories.length ? <ul aria-label="Accessible GitHub repositories" tabIndex={0} className="max-h-(--sz-github-repository-list) space-y-2 overflow-y-auto text-sm">
{github.repositories.map((repository) => (
<li key={repository.id}>
<a className="flex items-center gap-2 text-muted-foreground hover:underline" href={`https://github.com/${repository.fullName.split("/").map(encodeURIComponent).join("/")}`} target="_blank" rel="noreferrer">
<GithubIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
@ -401,7 +365,7 @@ function GitHubConnectionSummary({
</li>
))}
</ul> : <p role="status" className="text-sm text-muted-foreground">
{search ? "No repositories match your search." : "No accessible repositories for this account or organization."}
No accessible repositories.
</p>
) : (
<p className="text-sm text-muted-foreground">Refresh access to load the current repository list.</p>