Files
PaperClipAI/ui/src/components/ProjectProperties.managed-sandbox.test.tsx
T
DottaandPaperclip b97101893f feat(projects): select multiple GitHub source repositories (#13010)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects give tasks a common source repository and execution
context.
> - The current project form asks for a raw URL and unrelated metadata.
> - Teams need to select several repos from GitHub connections they can
use.
> - This pull request implements the reviewed project form and
repository editor.
> - The server checks credential ownership and shared audiences before
discovery.
> - Existing workspace URLs and runtime identity rules remain
compatible.

## Linked Issues or Issue Description

**Problem or motivation**

Project creation accepts one raw repository URL. It does not help users
select repos from their usable GitHub connections or attach several
repos together.

**Proposed solution**

Add a shared GitHub repository picker to project creation and
Configuration. Support multiple selections, transactional persistence,
and the existing GitHub setup flow. Simplify the project form and
Configuration tab as reviewed.

**Alternatives considered**

Keep a raw URL field or add a separate repository table. The existing
workspace collection already supports several repositories and keeps
legacy URLs compatible.

**Roadmap alignment**

This builds on the shipped MCP Tool Gateway and Apps capability. It does
not change runtime credential delegation.

Related work: #11662 addresses the existing dialog's viewport limits.
#4552 addresses generic Git URLs; this change preserves those URLs in
existing workspaces.

## What Changed

- Add company-scoped repository discovery from usable personal and
shared GitHub grants, with provider-ID deduplication, PAT pagination,
and partial failure handling.
- Document the repository endpoints and board access requirements in
OpenAPI.
- Validate new selections and save projects with multiple repository
workspaces in one transaction. Preserve legacy URLs and existing
selections whose access was lost.
- Implement the reviewed Create project dialog, shared repository
editor, scrolling, and mobile layout.
- Move repositories above environment variables, remove Status and Goals
controls and env help paragraphs, move Created to the bottom, and
redirect Overview to Configuration.
- Reuse GitHub setup in dialogs, preserve project drafts, and verify
popup completion through the API.
- Replace the configuration story's DOM adapter with explicit production
composition. Keep the reviewed mobile and short-viewport stories.

## Verification

- Passed: `pnpm build`, `pnpm -r typecheck`, `pnpm build-storybook`, and
`pnpm check:token-gates`.
- Passed: focused repository access, database persistence,
configuration, and connection setup tests.
- Passed: `pnpm exec playwright test --config
tests/e2e/playwright.config.ts tests/e2e/project-repositories.spec.ts`.
- The browser tests use a real temporary server/database. They cover
create, forty persisted repos, mobile scrolling, save/reload, legacy URL
editing, and rejection without a partial project.
- GitHub responses and popup completion use deterministic fixtures. No
real GitHub account was authorized by the test suite.
- All CI general, serialized server, and browser test shards pass on the
final commit.
- The local full-suite run overlapped review edits and was stopped;
fresh repository, OpenAPI, UI/CLI, and connection tests pass. Unrelated
local worker, built-in-agent, and routine timing/socket failures passed
isolated reruns.
- Final commit `1b3308dca`: all CI gates pass, including build, runner
verification, typecheck, canary dry run, and security checks. Greptile
is 5/5 with no unresolved review threads.
- Storybook visual regression is opt-in and was skipped by CI; the
Storybook build passed locally.

## Risks

- Repository discovery depends on provider availability. Failed
connections are reported while successful results stay usable.
- Selections identify source workspaces; they do not grant agents new
credentials. The existing primary-workspace and responsible-user
identity rules still apply.
- No database migration is needed. Existing API status, goals, dates,
and manual workspace URLs remain supported.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code
execution, and browser tools. The runtime does not expose a more
specific model deployment ID or context-window size.

## 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-08 08:21:28 -05:00

167 lines
6.4 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ProjectCodebase } from "@paperclipai/shared";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectProperties } from "./ProjectProperties";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(() => {
callback();
});
}
const noop = vi.hoisted(() => () => undefined);
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({}) } }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
}));
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
const LOCAL_FOLDER = "/Users/paperclip/projects/test-project";
const MANAGED_FOLDER = "/var/paperclip/checkouts/test-project";
function makeCodebase(overrides: Partial<ProjectCodebase> = {}): ProjectCodebase {
return {
workspaceId: "workspace-1",
repoUrl: "https://github.com/paperclipai/paperclip",
repoRef: "master",
defaultRef: "origin/master",
repoName: "paperclipai/paperclip",
localFolder: LOCAL_FOLDER,
managedFolder: MANAGED_FOLDER,
effectiveLocalFolder: LOCAL_FOLDER,
origin: "local_folder",
...overrides,
};
}
function makeProject(codebase: ProjectCodebase): Project {
return {
id: "project-1",
urlKey: "project-1",
name: "Test project",
description: "",
status: "in_progress",
goalIds: [],
goals: [],
env: null,
codebase,
primaryWorkspace: null,
workspaces: [],
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
} as unknown as Project;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
/** Pass `null` for `experimentalSettings` to render with the policy unresolved. */
function render(project: Project, experimentalSettings: Record<string, unknown> | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (experimentalSettings) {
client.setQueryData(queryKeys.instance.experimentalSettings, experimentalSettings);
}
act(() => {
root.render(
<QueryClientProvider client={client}>
<TooltipProvider>
<ProjectProperties project={project} onFieldUpdate={vi.fn()} getFieldSaveState={() => "idle"} onArchive={noop} />
</TooltipProvider>
</QueryClientProvider>,
);
});
}
function buttonLabels() {
return Array.from(container.querySelectorAll("button")).map((button) => button.textContent?.trim() ?? "");
}
describe("ProjectProperties — local folder under the managed-sandbox-only policy", () => {
it("shows the folder path and its controls when the policy is off", () => {
render(makeProject(makeCodebase()), { enableIsolatedWorkspaces: true });
expect(container.textContent).toContain("Local folder");
expect(container.textContent).toContain(LOCAL_FOLDER);
expect(buttonLabels()).toContain("Change local folder");
expect(container.querySelector('button[aria-label="Clear local folder"]')).not.toBeNull();
});
it("hides the folder path and its controls when the policy is on", () => {
render(makeProject(makeCodebase()), {
enableIsolatedWorkspaces: true,
enableManagedSandboxOnly: true,
});
expect(container.textContent).not.toContain("Local folder");
expect(container.textContent).not.toContain(LOCAL_FOLDER);
expect(buttonLabels()).not.toContain("Change local folder");
expect(container.querySelector('button[aria-label="Clear local folder"]')).toBeNull();
// The repo row is unrelated to the host filesystem, so it stays.
expect(container.textContent).toContain("Source repos");
});
it("omits the host codebase section for a managed checkout when the policy is on", () => {
render(
makeProject(makeCodebase({
localFolder: null,
effectiveLocalFolder: MANAGED_FOLDER,
origin: "managed_checkout",
})),
{ enableIsolatedWorkspaces: true, enableManagedSandboxOnly: true },
);
expect(container.textContent).not.toContain("Codebase");
expect(container.textContent).not.toContain(MANAGED_FOLDER);
expect(container.querySelector(".font-mono")?.textContent).not.toBe(MANAGED_FOLDER);
expect(buttonLabels()).not.toContain("Set local folder");
});
it("keeps the folder path hidden while the policy is still loading", () => {
// A cold cache resolves the policy to false on the first render. The guard
// fails closed so a managed instance never flashes the execution-host path.
render(makeProject(makeCodebase()), null);
expect(container.textContent).not.toContain("Local folder");
expect(container.textContent).not.toContain(LOCAL_FOLDER);
expect(container.textContent).toContain("Source repos");
});
it("never opens the absolute-path edit panel when the policy is on", () => {
render(makeProject(makeCodebase()), {
enableIsolatedWorkspaces: true,
enableManagedSandboxOnly: true,
});
const pathInput = container.querySelector('input[placeholder="/absolute/path/to/workspace"]');
expect(pathInput).toBeNull();
});
});