mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## 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>
96 lines
4.7 KiB
TypeScript
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" }),
|
|
});
|
|
});
|
|
});
|