feat: browse GitHub repository access across organizations (#12998)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - GitHub connections give agents access to approved repositories.
> - One GitHub identity can use installations across several
organizations.
> - The permissions page linked to one installation and showed an
unfiltered list.
> - Users could not easily find another organization or inspect a large
selection.
> - This change adds account filtering, search, and access configuration
links.
> - Users can inspect repository access in one compact view.

## Linked Issues or Issue Description

Refs #12993. Related repository-catalog work in #11228 and #11234 was
checked. This change only improves the existing GitHub connection
permissions page.

**What existing behavior does this improve?**

The GitHub connection permissions page and its repository display
metadata.

**Current behavior**

The page links directly to an existing installation. The repository list
has no account filter, search, height limit, or private-repository
marker. Refresh access occupies a separate section.

**Proposed behavior**

Show all authorized repositories by default. Filter by account or
organization and search by name. Open GitHub's account chooser to
configure access across organizations. Show GitHub icons and
private-repository locks. Keep refresh beside configuration and limit
the visible list to about ten rows.

**Reason and benefit**

Users can find repositories across organizations and configure missing
access without creating another GitHub identity. Large repository lists
no longer fill the page.

**Breaking changes**

None. Repository display metadata gains an optional private flag. Older
snapshots remain valid and gain the flag after access refresh. No SQL
migration is required.

## What Changed

- Add an All accounts view, account filter, search, and empty states.
- Link both configuration controls to GitHub's app account chooser.
- Place an accessible refresh icon beside the configuration button.
- Keep the repository heading and list in one section.
- Add GitHub icons and private-repository locks.
- Cap the scrollable list at ten rows using a design token.
- Persist GitHub's private flag only when the provider returns a
boolean.
- Recover missing legacy app configuration from GitHub installation
metadata.
- Update tests and the GitHub connection runbook.

## Verification

- Focused tests passed: 54 permissions-page tests and four GitHub
metadata tests.
- UI and server typechecks passed before submission. Token gates passed.
- Browser checks verified account filtering, search, empty results, and
the configuration destination.
- The live list contained 40 repositories. Its final height was 272
pixels, which fits ten single-line rows with gaps. Scrolling retained
all rows.
- A live access refresh populated 30 private-repository lock icons from
GitHub metadata.
- Full workspace typecheck and build passed. The broad local suite
stopped in the general-server group with 18 failed files. Failures
include macOS temporary-path handling and embedded PostgreSQL startup.
That run also overlapped the legacy fix and retained a stale GitHub
module; the final focused run passed all 58 tests. Clean-runner CI is
tracked separately.
- Latest-head review is 5/5 with the legacy chooser finding resolved.
All CI checks passed on commit
`0ff2b63f348f5c87d8b7df6e43388f60f5d872d9`, including build, typecheck,
all test shards, browser tests, and canary dry run.

## Risks

- Older repository snapshots lack visibility metadata until refreshed.
Unknown visibility does not display a lock.
- The account filter lists authorized installation owners. Users add
other organizations through GitHub's chooser.
- Filtering changes only the displayed list. GitHub remains
authoritative for repository access.

## Model Used

OpenAI GPT-6 (`gpt-6-astra`) via Codex. Reasoning, code execution, and
browser tools were used. The exact context window size was not exposed.

## 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-07 10:01:58 -05:00 committed by GitHub
parent bac60d9d31
commit 932ddb7b37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 225 additions and 62 deletions

View File

@ -73,9 +73,19 @@ OAuth completion verifies `/user`, every page of `/user/installations`, and ever
page of each installation's accessible repositories. Setup remains incomplete
until at least one installation and repository are available. Paperclip stores
the authenticated username and a grant-scoped display snapshot containing only
repository IDs, full names, and installation IDs. GitHub stays authoritative:
repository IDs, full names, installation IDs, and private-repository flags. GitHub stays authoritative:
this snapshot never authorizes repository access.
The permissions page shows repositories across authorized accounts by default.
Use the account filter and search to narrow the list. The list scrolls after
about ten rows and marks known private repositories with a lock. Configure on
GitHub opens the app account chooser so users can add or update organization
access. Refresh access after changing the selection. Older snapshots omit the
private flag until refreshed. If a legacy grant lacks its app chooser URL,
**Load GitHub configuration** refreshes access and recovers the app slug from
GitHub installation metadata. The page does not substitute a single-installation
settings URL for the account chooser.
The permissions page shows the authenticated GitHub account and the complete
accessible repository list. **Refresh access** reloads it from GitHub. Older
grants and grants invalidated by newer installation lifecycle events prompt for a

View File

@ -195,7 +195,7 @@ export const connectionGrants = pgTable(
installationIds: string[];
installationOwnerLogins: string[];
/** Repository metadata visible to this credential; refreshed from GitHub. */
repositories?: Array<{ id: string; fullName: string; installationId: string }>;
repositories?: Array<{ id: string; fullName: string; installationId: string; private?: boolean }>;
installationUrl?: string;
managementUrl?: string;
appSlug?: string;

View File

@ -225,7 +225,7 @@ export interface ConnectionGrant {
installationIds: string[];
installationOwnerLogins: string[];
/** Repository metadata visible to this credential; refreshed from GitHub. */
repositories?: Array<{ id: string; fullName: string; installationId: string }>;
repositories?: Array<{ id: string; fullName: string; installationId: string; private?: boolean }>;
installationUrl?: string;
managementUrl?: string;
appSlug?: string;

View File

@ -24,8 +24,8 @@ describe("GitHub grant metadata", () => {
}
if (url.pathname === "/user/installations/101/repositories") {
return json({ total_count: 2, repositories: secondPage
? [{ id: 2, full_name: "paperclipai/b", description: "must-not-persist", clone_url: "must-not-persist" }]
: [{ id: 1, full_name: "paperclipai/a" }],
? [{ id: 2, full_name: "paperclipai/b", private: true, description: "must-not-persist", clone_url: "must-not-persist" }]
: [{ id: 1, full_name: "paperclipai/a", private: false }],
}, !secondPage);
}
if (url.pathname === "/user/installations/102/repositories") {
@ -45,14 +45,15 @@ describe("GitHub grant metadata", () => {
installationOwnerLogins: ["paperclipai", "octocat"],
repositories: [
{ id: "3", fullName: "octocat/c", installationId: "102" },
{ id: "1", fullName: "paperclipai/a", installationId: "101" },
{ id: "2", fullName: "paperclipai/b", installationId: "101" },
{ id: "1", fullName: "paperclipai/a", installationId: "101", private: false },
{ id: "2", fullName: "paperclipai/b", installationId: "101", private: true },
],
installationUrl: "https://github.com/apps/paperclip-development/installations/new",
managementUrl: "https://github.com/settings/installations/101",
appSlug: "paperclip-development",
webhookHealth: "pending",
});
expect(metadata.repositories[0]).not.toHaveProperty("private");
expect(JSON.stringify(metadata)).not.toContain("must-not-persist");
expect(request).toHaveBeenCalledTimes(6);
for (const [input, init] of request.mock.calls) {
@ -61,6 +62,17 @@ describe("GitHub grant metadata", () => {
}
});
it("recovers a legacy grant's app chooser from GitHub installation metadata", async () => {
const request = vi.fn<typeof fetch>()
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
.mockResolvedValueOnce(json({ installations: [{ id: 101, app_slug: "paperclip-staging", repository_selection: "selected" }] }))
.mockResolvedValueOnce(json({ repositories: [{ id: 1, full_name: "octocat/a" }] }));
await expect(loadGitHubGrantMetadata("ghu_secret", request)).resolves.toMatchObject({
appSlug: "paperclip-staging",
installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
});
});
it("requires at least one installation with an accessible repository", async () => {
const request = vi.fn<typeof fetch>(async (input) => String(input).endsWith("/user")
? json({ id: 42, login: "octocat" })

View File

@ -1942,7 +1942,7 @@ export async function loadGitHubGrantMetadata(
repositorySelection: "all" | "selected" | "mixed" | "none";
installationIds: string[];
installationOwnerLogins: string[];
repositories: Array<{ id: string; fullName: string; installationId: string }>;
repositories: Array<{ id: string; fullName: string; installationId: string; private?: boolean }>;
installationUrl: string;
managementUrl: string;
appSlug?: string;
@ -1950,6 +1950,7 @@ export async function loadGitHubGrantMetadata(
lastAccessRefreshAt: string;
webhookHealth: "pending";
}> {
let resolvedAppSlug = appSlug;
const accessRefreshStartedAt = new Date().toISOString();
const github = async (path: string): Promise<{ data: Record<string, unknown>; hasNext: boolean }> => {
const response = await request(`https://api.github.com${path}`, {
@ -1991,11 +1992,17 @@ export async function loadGitHubGrantMetadata(
const owners = new Set<string>();
const selections = new Set<"all" | "selected">();
const managementUrls = new Set<string>();
const repositories = new Map<string, { id: string; fullName: string; installationId: string }>();
const repositories = new Map<string, { id: string; fullName: string; installationId: string; private?: boolean }>();
for (const installation of installations) {
const installationId = githubId(installation.id);
if (!installationId) continue;
installationIds.push(installationId);
// Older grants predate the broker's appSlug field. GitHub's installation
// response identifies this token's app without choosing an environment.
if (!resolvedAppSlug && typeof installation.app_slug === "string"
&& /^[a-z0-9-]{1,100}$/.test(installation.app_slug)) {
resolvedAppSlug = installation.app_slug;
}
if (installation.repository_selection === "all" || installation.repository_selection === "selected") {
selections.add(installation.repository_selection);
}
@ -2009,13 +2016,16 @@ export async function loadGitHubGrantMetadata(
if (!id || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fullName)) {
throw unprocessable("GitHub returned invalid repository metadata", { code: "github_bad_response" });
}
repositories.set(id, { id, fullName, installationId });
repositories.set(id, {
id, fullName, installationId,
...(typeof repository.private === "boolean" ? { private: repository.private } : {}),
});
}
}
const repositoryCount = repositories.size;
if (installationIds.length === 0 || repositoryCount === 0) {
const installationUrl = appSlug
? `https://github.com/apps/${appSlug}/installations/new`
const installationUrl = resolvedAppSlug
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
: "https://github.com/settings/installations";
throw unprocessable("GitHub access is required. Install Paperclip and grant at least one repository before refreshing access.", {
code: "github_installation_required",
@ -2023,8 +2033,8 @@ export async function loadGitHubGrantMetadata(
managementUrl: "https://github.com/settings/installations",
});
}
const installationUrl = appSlug
? `https://github.com/apps/${appSlug}/installations/new`
const installationUrl = resolvedAppSlug
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
: "https://github.com/settings/installations";
return {
userId,
@ -2040,7 +2050,7 @@ export async function loadGitHubGrantMetadata(
managementUrl: managementUrls.size === 1
? managementUrls.values().next().value!
: "https://github.com/settings/installations",
...(appSlug ? { appSlug } : {}),
...(resolvedAppSlug ? { appSlug: resolvedAppSlug } : {}),
accessRevision: randomUUID(),
lastAccessRefreshAt: accessRefreshStartedAt,
webhookHealth: "pending",

View File

@ -2366,6 +2366,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
for the full site list).
*/
:root {
/* Ten single-line repository rows, including the gaps between them. */
--sz-github-repository-list: calc(10lh + 9 * var(--spacing) * 2);
--sz-320px: 320px; /* Extracted from ui/src/components/ActiveAgentsPanel.tsx (h-[320px]). */
--sz-390px: 390px;
--sz-64px: 64px;

View File

@ -25,6 +25,7 @@ const finishAppMock = vi.hoisted(() => vi.fn());
const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
const refreshCatalogMock = vi.hoisted(() => vi.fn());
const checkConnectionHealthMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
const revokeConnectionGrantMock = vi.hoisted(() => vi.fn());
@ -67,6 +68,7 @@ vi.mock("@/api/tools", () => ({
putConnectionInstallsMock(connectionId, installs),
archiveConnection: vi.fn(),
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
checkConnectionHealth: (connectionId: string) => checkConnectionHealthMock(connectionId),
startOAuth: (connectionId: string, input?: unknown) => input === undefined
? startOAuthMock(connectionId)
: startOAuthMock(connectionId, input),
@ -282,6 +284,7 @@ function dedicatedGitHubGrant(
installationIds: ["456"],
installationOwnerLogins: ["paperclipai"],
repositories: [{ id: "789", fullName: "paperclipai/test-repo", installationId: "456" }],
installationUrl: "https://github.com/apps/paperclip-test/installations/new",
managementUrl: "https://github.com/settings/installations/456",
webhookHealth: "pending",
lastWebhookAt: null,
@ -409,6 +412,7 @@ describe("AppDetail", () => {
finishAppMock.mockResolvedValue({});
finalizeOAuthAccessMock.mockResolvedValue({});
putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
checkConnectionHealthMock.mockResolvedValue({ connection: connection(), healthStatus: "ok" });
refreshCatalogMock.mockResolvedValue({ discoveredCount: 0, quarantinedCount: 0, catalog: [] });
startOAuthMock.mockResolvedValue({
connectionId: "conn-1",
@ -1464,8 +1468,8 @@ describe("AppDetail", () => {
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
repositoryCount: 2,
repositories: [
{ id: "1", fullName: "paperclipai/first", installationId: "456" },
{ id: "2", fullName: "paperclipai/second", installationId: "456" },
{ id: "1", fullName: "paperclipai/first", installationId: "456", private: true },
{ id: "2", fullName: "paperclipai/second", installationId: "456", private: false },
],
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
@ -1476,6 +1480,82 @@ describe("AppDetail", () => {
expect(container.querySelectorAll('ul[aria-label="Accessible GitHub repositories"] li')).toHaveLength(2);
expect(container.textContent).toContain("paperclipai/first");
expect(container.textContent).toContain("paperclipai/second");
expect(container.querySelector('a[href="https://github.com/paperclipai/first"] [aria-label="Private repository"]')).toBeTruthy();
expect(container.querySelector('a[href="https://github.com/paperclipai/second"] [aria-label="Private repository"]')).toBeNull();
const configureHint = [...container.querySelectorAll("p a")].find((link) => link.textContent === "Configure access on GitHub");
expect(configureHint?.getAttribute("href")).toBe("https://github.com/apps/paperclip-test/installations/new");
});
it.each([undefined, "https://github.com/settings/installations"])("loads missing GitHub app configuration instead of linking legacy settings (%s)", async (installationUrl) => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(perUserConnection());
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, { installationUrl })],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
await renderAppDetail();
expect(findButton("Load GitHub configuration")).toBeTruthy();
expect(container.querySelector('a[href="https://github.com/settings/installations/456"]')).toBeNull();
expect(container.querySelector('a[href="https://github.com/settings/installations"]')).toBeNull();
listConnectionGrantsMock.mockResolvedValue({
connection: { id: "conn-1", uid: "conn-1" },
grants: [dedicatedGitHubGrant({ kind: "user", subjectAgentId: null, subjectUserId: "user-1" }, {
appSlug: "paperclip-staging", installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
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(findButton("Load GitHub configuration")).toBeUndefined();
});
it("filters the combined GitHub repository list by owner and search without changing access", async () => {
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,
installationOwnerLogins: ["paperclipai", "dottabot", "empty-org"],
repositories: [
{ id: "1", fullName: "paperclipai/first", installationId: "456" },
{ id: "2", fullName: "paperclipai/second", installationId: "456" },
{ id: "3", fullName: "dottabot/first", installationId: "789" },
],
})],
capabilities: fullCapabilities(), currentUserId: "user-1", members: [],
});
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(updateConnectionMock).not.toHaveBeenCalled();
});
it("shows dedicated GitHub access as compact action rows and links to the agent", async () => {
@ -1500,9 +1580,9 @@ describe("AppDetail", () => {
expect(container.querySelector('a[href="https://github.com/dottabot"]')?.textContent).toBe("@dottabot");
expect(container.querySelector('a[href="https://github.com/paperclipai/test-repo"]')?.textContent).toBe("paperclipai/test-repo");
expect(container.querySelector(
'a[href="https://github.com/settings/installations/456"]',
)?.textContent).toBe("Manage repositories on GitHub");
expect(findButton("Refresh access")).toBeTruthy();
'a[href="https://github.com/apps/paperclip-test/installations/new"]',
)?.textContent).toBe("Configure on GitHub");
expect(container.querySelector('button[aria-label="Refresh access"]')).toBeTruthy();
expect(container.textContent).not.toContain("Installation");
expect(container.textContent).not.toContain("Token continuity");
expect(container.textContent).not.toContain("Webhook health");

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Building2, Loader2, TriangleAlert, UserRound } from "lucide-react";
import { Building2, Loader2, Lock, RefreshCw, Search, TriangleAlert, UserRound } from "lucide-react";
import type {
ConnectionAudienceMember,
ConnectionGrant,
@ -8,6 +8,9 @@ import type {
} from "@paperclipai/shared";
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";
@ -291,8 +294,29 @@ 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 ?? "")
? github.installationUrl
: null;
const repositoryWarning = github.repositorySelection === "all"
? "All current and future repositories"
: github.repositorySelection === "mixed"
@ -309,56 +333,81 @@ function GitHubConnectionSummary({
@{github.login}
</a>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Repositories</div>
{repositoryWarning ? (
<div
role="note"
className={cn(
"mt-1 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs",
brandBanner.warning,
)}
>
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{repositoryWarning}
</div>
) : (
<div className="text-xs text-muted-foreground">{repositorySummary}</div>
)}
<div className="space-y-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Repositories</div>
{repositoryWarning ? (
<div
role="note"
className={cn(
"mt-1 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs",
brandBanner.warning,
)}
>
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{repositoryWarning}
</div>
) : (
<div className="text-xs text-muted-foreground">{repositorySummary}</div>
)}
</div>
<div className="flex items-center gap-2">
{onRefreshAccess && configurationUrl ? (
<Button size="icon-sm" variant="outline" aria-label="Refresh access" title="Refresh access" disabled={refreshPending} onClick={onRefreshAccess}>
{refreshPending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <RefreshCw aria-hidden="true" />}
</Button>
) : null}
{configurationUrl ? (
<Button asChild size="sm" variant="outline">
<a href={configurationUrl} target="_blank" rel="noreferrer">Configure on GitHub</a>
</Button>
) : onRefreshAccess ? (
<Button size="sm" variant="outline" disabled={refreshPending} onClick={onRefreshAccess}>
{refreshPending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <RefreshCw aria-hidden="true" />}
Load GitHub configuration
</Button>
) : 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.managementUrl ? (
<Button asChild size="sm" variant="outline">
<a href={github.managementUrl} target="_blank" rel="noreferrer">Manage repositories on GitHub</a>
</Button>
) : null}
</div>
<div className="py-3">
{github.repositories ? (
<ul aria-label="Accessible GitHub repositories" className="space-y-2 text-sm">
{github.repositories.map((repository) => (
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) => (
<li key={repository.id}>
<a className="break-all text-muted-foreground hover:underline" href={`https://github.com/${repository.fullName.split("/").map(encodeURIComponent).join("/")}`} target="_blank" rel="noreferrer">
{repository.fullName}
<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" />
<span className="break-all">{repository.fullName}</span>
{repository.private === true ? <Lock className="h-3 w-3 shrink-0" role="img" aria-label="Private repository" /> : null}
</a>
</li>
))}
</ul>
</ul> : <p role="status" className="text-sm text-muted-foreground">
{search ? "No repositories match your search." : "No accessible repositories for this account or organization."}
</p>
) : (
<p className="text-sm text-muted-foreground">Refresh access to load the current repository list.</p>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Refresh access</div>
<div className="text-xs text-muted-foreground">Sync repository access from GitHub.</div>
</div>
{onRefreshAccess ? (
<Button size="sm" variant="outline" disabled={refreshPending} onClick={onRefreshAccess}>
{refreshPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
Refresh access
</Button>
) : null}
{configurationUrl ? <p className="text-xs text-muted-foreground">
Missing an organization or repository? <a href={configurationUrl} target="_blank" rel="noreferrer" className="text-foreground hover:underline">Configure access on GitHub</a>, then refresh this list.
</p> : null}
</div>
</div>
);