Files
PaperClipAI/server/src/__tests__/github-grant-metadata.test.ts
T
DottaandPaperclip 932ddb7b37 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>
2026-09-07 10:01:58 -05:00

96 lines
4.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { loadGitHubGrantMetadata } from "../services/tool-access.js";
function json(value: unknown, next = false): Response {
return new Response(JSON.stringify(value), {
headers: {
"content-type": "application/json",
...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}),
},
});
}
describe("GitHub grant metadata", () => {
it("lists every page of installations and repositories, persisting only display metadata", async () => {
const request = vi.fn<typeof fetch>(async (input) => {
const url = new URL(String(input));
const secondPage = url.searchParams.get("page") === "2";
if (url.pathname === "/user") return json({ id: 42, login: "octocat", avatar_url: "https://avatars.example/octocat" });
if (url.pathname === "/user/installations") {
return json({ installations: secondPage
? [{ id: 102, repository_selection: "all", account: { login: "octocat" } }]
: [{ id: 101, repository_selection: "selected", html_url: "https://github.com/settings/installations/101", account: { login: "paperclipai" } }],
}, !secondPage);
}
if (url.pathname === "/user/installations/101/repositories") {
return json({ total_count: 2, repositories: secondPage
? [{ 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") {
return json({ total_count: 1, repositories: [{ id: 3, full_name: "octocat/c" }] });
}
throw new Error(`Unexpected GitHub path: ${url.pathname}`);
});
const metadata = await loadGitHubGrantMetadata("ghu_secret", request, "paperclip-development");
expect(metadata).toMatchObject({
userId: "42",
login: "octocat",
installationCount: 2,
repositoryCount: 3,
repositorySelection: "mixed",
installationIds: ["101", "102"],
installationOwnerLogins: ["paperclipai", "octocat"],
repositories: [
{ id: "3", fullName: "octocat/c", installationId: "102" },
{ 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) {
expect(new URL(String(input)).origin).toBe("https://api.github.com");
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer ghu_secret");
}
});
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" })
: json({ installations: [] }));
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
details: expect.objectContaining({ code: "github_installation_required" }),
});
});
it("does not report a partial repository list when a later page fails", async () => {
const request = vi.fn<typeof fetch>()
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
.mockResolvedValueOnce(json({ installations: [{ id: 101, repository_selection: "selected" }] }))
.mockResolvedValueOnce(json({ repositories: [{ id: 1, full_name: "octocat/a" }] }, true))
.mockResolvedValueOnce(new Response("Unavailable", { status: 503 }));
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
details: expect.objectContaining({ code: "github_access_check_failed" }),
});
});
});