mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## 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>
167 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|