Files
PaperClipAI/tests/e2e/project-repositories.spec.ts
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

85 lines
6.1 KiB
TypeScript

import { expect, test } from "@playwright/test";
test("project creation and repository configuration persist on a short mobile viewport", async ({ page }) => {
test.setTimeout(120_000);
const createdCompany = await page.request.post("/api/companies", { data: { name: "Repository UX" } });
expect(createdCompany.ok()).toBe(true);
const company = await createdCompany.json();
await page.goto(`/${company.issuePrefix}/projects`);
await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("textbox", { name: "Project name" })).toBeFocused();
await dialog.getByRole("textbox", { name: "Project name" }).fill("Repository acceptance");
await expect(dialog.getByText("Source repos", { exact: true })).toBeVisible();
await expect(dialog.getByText("Goal", { exact: true })).toHaveCount(0);
await expect(dialog.getByPlaceholder("https://github.com/org/repo")).toHaveCount(0);
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(dialog).toHaveCount(0);
const projects = await (await page.request.get(`/api/companies/${company.id}/projects`)).json();
const project = projects.find((item: { name: string }) => item.name === "Repository acceptance");
expect(project).toBeTruthy();
// Seed existing selections through the real workspace API. Provider discovery
// and create authorization are separately covered by server integration tests.
for (let index = 1; index <= 40; index += 1) {
const response = await page.request.post(`/api/projects/${project.id}/workspaces`, { data: {
name: `org/repo-${index}`, repoUrl: `https://github.com/org/repo-${index}`, metadata: { githubRepositoryId: String(index) },
} });
expect(response.ok()).toBe(true);
}
const legacy = await page.request.post(`/api/projects/${project.id}/workspaces`, { data: { name: "Legacy", repoUrl: "https://git.example/org/legacy" } });
expect(legacy.ok()).toBe(true);
await page.setViewportSize({ width: 390, height: 420 });
await page.goto(`/${company.issuePrefix}/projects/${project.id}/overview`);
await expect(page).toHaveURL(/\/configuration$/);
await expect(page.getByRole("tab", { name: "Overview", exact: true })).toHaveCount(0);
const section = page.getByRole("region", { name: "Repositories", exact: true });
await expect(section.getByText("org/repo-40", { exact: true })).toBeVisible();
await section.getByRole("button", { name: "Remove org/repo-40", exact: true }).click();
await section.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(section.getByRole("status")).toHaveText("Changes saved");
await expect(section.getByText("org/repo-40", { exact: true })).toHaveCount(0);
await page.reload();
await expect(section.getByText("org/repo-40", { exact: true })).toHaveCount(0);
await expect(section.getByText("org/repo-39", { exact: true })).toBeVisible();
await section.getByRole("button", { name: "Edit", exact: true }).click();
await section.getByRole("textbox", { name: "Existing repo URL" }).fill("https://git.example/org/updated");
await section.getByRole("button", { name: "Save URL" }).click();
await page.reload();
await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible();
await expect(page.getByText("Set the KEY to the env var name", { exact: false })).toHaveCount(0);
await expect(page.getByText("Applied to all runs for tasks in this project.", { exact: false })).toHaveCount(0);
const created = page.getByText("Created", { exact: true });
await created.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const persisted = await (await page.request.get(`/api/projects/${project.id}`)).json();
expect(persisted.workspaces.filter((workspace: { metadata?: { githubRepositoryId?: string } }) => workspace.metadata?.githubRepositoryId)).toHaveLength(39);
});
test("a short new-project dialog keeps actions visible and rejects unavailable selections without a partial project", async ({ page }) => {
const company = await (await page.request.post("/api/companies", { data: { name: "Repository picker" } })).json();
const repos = Array.from({ length: 80 }, (_, index) => ({ id: String(index + 1), fullName: `org/repo-${index + 1}`, url: `https://github.com/org/repo-${index + 1}`, connections: ["Fixture account"], private: true }));
// Only discovery is simulated. Submission reaches the real authorization
// boundary and must reject these unavailable provider identities atomically.
await page.route(`**/api/companies/${company.id}/project-repositories`, (route) => route.fulfill({ json: { repositories: repos, connectionCount: 1, failedConnectionCount: 0 } }));
await page.setViewportSize({ width: 390, height: 420 });
await page.goto(`/${company.issuePrefix}/projects`);
await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "Project name" }).fill("Rejected draft");
await dialog.getByRole("button", { name: "Add GitHub repo", exact: true }).click();
await page.getByPlaceholder("Search GitHub repos…").fill("org/repo-80");
await page.getByRole("option").filter({ hasText: "org/repo-80" }).click();
await dialog.getByRole("button", { name: "Add another repo", exact: true }).click();
await page.getByPlaceholder("Search GitHub repos…").fill("org/repo-79");
await page.getByRole("option").filter({ hasText: "org/repo-79" }).click();
const submit = dialog.getByRole("button", { name: "Create project", exact: true });
const bounds = await submit.boundingBox();
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(420);
await submit.click();
await expect(dialog.getByRole("alert")).toContainText("no longer available");
await expect(dialog.getByRole("textbox", { name: "Project name" })).toHaveValue("Rejected draft");
await expect(dialog.getByText("org/repo-80", { exact: true })).toBeVisible();
const projects = await (await page.request.get(`/api/companies/${company.id}/projects`)).json();
expect(projects).toHaveLength(0);
});