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:
parent
bac60d9d31
commit
932ddb7b37
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue