diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 542dad3b99..c218f200a5 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -216,6 +216,14 @@ Invariant: Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. +Project source repositories use the existing `project_workspaces` collection. +Each selected GitHub repository has a canonical `repo_url` and stable provider ID +in `metadata.githubRepositoryId`; the first workspace remains the execution default. +The board can select multiple repositories from its usable personal and shared +GitHub grants. Selection does not grant runtime credential access. Legacy workspace +URLs remain valid. Project creation and repository replacement are transactional. +See `doc/project-repositories.md` for the API and UI contract. + ## 7.6 `issues` (core task entity) - `id` uuid pk diff --git a/doc/project-repositories.md b/doc/project-repositories.md new file mode 100644 index 0000000000..779cf6104f --- /dev/null +++ b/doc/project-repositories.md @@ -0,0 +1,69 @@ +# Project source repositories + +The Create project dialog accepts a name and optional GitHub repository selections. +It uses the same `RepositoryEditor` as project Configuration. Description remains +editable in Configuration. Status, goal links, and target dates remain supported by +the API but are omitted from creation; Status and Goals are omitted from Configuration. +Old Overview URLs and saved Overview preferences redirect to Configuration. + +## API and persistence + +- `GET /api/companies/:companyId/project-repositories` returns `repositories`, + `connectionCount`, and `failedConnectionCount`. Repository IDs are GitHub's stable + numeric IDs represented as strings. Results are deduplicated across accessible + grants and sorted by full name. Connection labels are display provenance only. +- `POST /api/companies/:companyId/projects` accepts optional `repositoryIds`. + The server resolves new selections through the caller's authorized GitHub grants + before creating the project and all repository workspaces in a transaction. + The existing `workspace` input remains supported; it cannot be combined with + `repositoryIds`. +- `PUT /api/projects/:id/repositories` accepts the selected `repositoryIds` array. + Accessible retained IDs refresh their canonical name and URL after renames or + transfers; unavailable retained IDs keep their saved metadata. + Replacement is transactional. Existing selections may be retained or removed even + if their GitHub connection becomes unavailable. New identities require current + access. Legacy URL workspaces are preserved, and matching legacy URLs are adopted + without creating a duplicate workspace. Local/remote workspace locations survive + detaching their repository. + +No schema migration is required. Selected repositories are normal project workspaces +with `metadata.githubRepositoryId`. Existing manual `repoUrl` workspaces remain +editable through Configuration and the workspace API. One workspace remains primary; +additional repositories do not change the existing runtime workspace-selection or +responsible-user credential rules. A repository selection never delegates credentials. + +## Discovery and setup + +The server checks company membership, grant ownership/status, and organization-grant +audiences before loading provider metadata. Connection managers receive no bypass to +another person's personal repositories. Managed GitHub grants refresh installation +access; PAT connections use paginated `/user/repos`. Provider failures are reported +without exposing provider error bodies or credential material. Successful connections +remain selectable when another connection fails. + +`ConnectionSetupFlow` owns provider setup in both Apps and project dialogs. Task +intents retain their existing callback protocol. Standalone dialogs verify the saved +connection through the API after the sign-in popup returns to the instance. Project +name and repository drafts stay mounted across setup and cancellation. + +## UI review and verification + +`Proposals/Project repos` contains the reviewed states, including loading, failure, +empty search, disconnected GitHub, multiple repos, legacy URLs, forty selections, +mobile, and short viewports. The configuration story composes the production page +properties through an explicit repositories slot. Story setup and saves use fixtures. + +- Shared visual control: `ui/src/components/RepositoryEditor.tsx`. +- Data and error handling: `ProjectRepositoryInput.tsx`. +- Configuration persistence and legacy editing: `ProjectRepositories.tsx` and + `LegacyProjectRepository.tsx`. +- Production dialog: `NewProjectDialog.tsx`. +- Server tests: `project-repositories.test.ts` and + `project-repositories-persistence.test.ts`. +- Browser acceptance: `tests/e2e/project-repositories.spec.ts`. + +The browser suite uses a real temporary server and database. It verifies creation, +forty persisted repos, mobile scrolling, removal/save/reload, legacy URL editing, +and rejection without a partial project. Provider discovery is simulated in the +picker rejection test. GitHub network and popup behavior use deterministic fixtures +in integration/component tests; the suite does not authorize a real GitHub account. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b364030569..7dd1be40d3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -928,6 +928,8 @@ export type { AssetImage, Project, ProjectBudgetSummary, + ProjectRepository, + ProjectRepositoryOptions, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1655b79236..e8d5dbd8e1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -349,7 +349,7 @@ export type { DocumentTextRange, UpdateDocumentAnnotationThreadRequest, } from "./document-annotation.js"; -export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; +export type { Project, ProjectRepository, ProjectRepositoryOptions, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchCountType, CompanySearchExtractIssueResult, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index d1f5114822..cda83d3144 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -112,3 +112,18 @@ export interface Project { createdAt: Date; updatedAt: Date; } + +/** GitHub identity is the provider's stable repository ID, never a credential. */ +export interface ProjectRepository { + id: string; + fullName: string; + url: string; + private?: boolean; + connections: string[]; +} + +export interface ProjectRepositoryOptions { + repositories: ProjectRepository[]; + connectionCount: number; + failedConnectionCount: number; +} diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 0668443867..d91e0dba9f 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -119,6 +119,7 @@ const projectFields = { export const createProjectSchema = z.object({ ...projectFields, workspace: createProjectWorkspaceSchema.optional(), + repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(), }); export type CreateProject = z.infer; diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 9f40581987..98f67f5a4a 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -280,6 +280,18 @@ describe("openapi routes", () => { }); }); + it("documents board-only repository discovery and selection", () => { + const { spec } = loadSpecRoutes(); + const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get; + const replacement = spec.paths["/api/projects/{id}/repositories"].put; + for (const operation of [discovery, replacement]) { + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + } + expect(replacement.requestBody.content["application/json"].schema.required).toContain("repositoryIds"); + expect(replacement.responses["422"]).toBeDefined(); + }); + it("documents auth and reviewed response-code invariants", () => { const { spec } = loadSpecRoutes(); diff --git a/server/src/__tests__/project-repositories-persistence.test.ts b/server/src/__tests__/project-repositories-persistence.test.ts new file mode 100644 index 0000000000..8c6c82888c --- /dev/null +++ b/server/src/__tests__/project-repositories-persistence.test.ts @@ -0,0 +1,115 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { toolAccessService } from "../services/tool-access.js"; +import { projectService } from "../services/projects.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +vi.mock("../services/secrets.js", () => ({ secretService: () => ({ + resolveSecretValue: async (_companyId: string, secretId: string) => secretId, +}) })); + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("project repository persistence", () => { + let temp: Awaited>; + let db: ReturnType; + let companyId: string; + const repo = (id: string) => ({ id, fullName: `org/repo-${id}`, url: `https://github.com/org/repo-${id}`, connections: [] }); + beforeAll(async () => { + temp = await startEmbeddedPostgresTestDatabase("paperclip-repositories-"); + db = createDb(temp.connectionString); + [companyId] = (await db.insert(companies).values({ name: "Repositories", issuePrefix: "REPO" }).returning()).map((company) => company.id); + }, 20_000); + afterAll(async () => { await temp?.cleanup(); }); + afterEach(() => vi.unstubAllGlobals()); + + it("creates multiple source repos atomically and persists them across reads", async () => { + const svc = projectService(db); + const created = await svc.createWithRepositories(companyId, { name: "Multi" }, [repo("1"), repo("2")]); + const fetched = await svc.getById(created.id); + expect(fetched?.workspaces.map((workspace) => workspace.repoUrl).sort()).toEqual([repo("1").url, repo("2").url]); + expect(fetched?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1); + expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true); + }); + it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => { + const svc = projectService(db); + const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]); + const legacy = await svc.createWorkspace(created.id, { repoUrl: "https://git.example/legacy/repo" }); + const local = await svc.createWorkspace(created.id, { cwd: "/tmp/local-project", repoUrl: repo("5").url, metadata: { githubRepositoryId: "5", retained: true } }); + const updated = await svc.replaceRepositories(created.id, [repo("4"), repo("6")]); + expect(updated?.workspaces.find((workspace) => workspace.id === legacy!.id)?.repoUrl).toBe("https://git.example/legacy/repo"); + expect(updated?.workspaces.find((workspace) => workspace.id === local!.id)).toMatchObject({ cwd: "/tmp/local-project", repoUrl: null, metadata: { retained: true } }); + expect(updated?.workspaces.some((workspace) => workspace.repoUrl === repo("3").url)).toBe(false); + expect(updated?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1); + const repeated = await svc.replaceRepositories(created.id, [repo("4"), repo("6")]); + expect(repeated?.workspaces).toHaveLength(updated!.workspaces.length); + }); + it("handles a repo recreated at the same URL with a new GitHub identity", async () => { + const svc = projectService(db); + const original = repo("20"); + const created = await svc.createWithRepositories(companyId, { name: "Recreated" }, [original]); + const updated = await svc.replaceRepositories(created.id, [{ ...original, id: "21" }]); + expect(updated?.workspaces).toHaveLength(1); + expect(updated?.workspaces[0]?.metadata?.githubRepositoryId).toBe("21"); + }); + + it("refreshes renamed and transferred repositories without replacing workspace identity or configuration", async () => { + const svc = projectService(db); + const created = await svc.createWithRepositories(companyId, { name: "Renamed" }, [repo("22")]); + const workspace = created.workspaces[0]; + await svc.updateWorkspace(created.id, workspace.id, { cwd: "/tmp/renamed-project", repoRef: "release", metadata: { ...workspace.metadata, retained: true } }); + const renamed = { ...repo("22"), fullName: "new-owner/new-name", url: "https://github.com/new-owner/new-name" }; + const updated = await svc.replaceRepositories(created.id, [renamed]); + expect(updated?.workspaces).toHaveLength(1); + expect(updated?.workspaces[0]).toMatchObject({ id: workspace.id, name: renamed.fullName, repoUrl: renamed.url, cwd: "/tmp/renamed-project", repoRef: "release", isPrimary: true, metadata: { githubRepositoryId: "22", retained: true } }); + expect(updated?.codebase.repoUrl).toBe(renamed.url); + }); + + it("loads only usable connection grants, deduplicates repos, and reports partial provider failures", async () => { + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "alice", membershipRole: "admin" }); + const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning(); + const tokenRepos = new Map>(); + async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId) { + const [app] = await db.insert(toolApplications).values({ companyId: targetCompanyId, name, type: "mcp_http" }).returning(); + const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name }).returning(); + const [conn] = await db.insert(toolConnections).values({ companyId: targetCompanyId, applicationId: app.id, name, uid: name, status: "active", enabled: true, + transport: "mcp_remote", authKind: "api_key", credentialPolicy: kind === "user" ? "per_user" : "shared", config: { sourceTemplateKey: "github" } }).returning(); + const [grant] = await db.insert(connectionGrants).values({ companyId: targetCompanyId, connectionId: conn.id, kind, subjectUserId: owner, + credentialSecretRefs: [{ secretId: secret.id, configPath: "credentials.authorization", versionSelector: "latest" }] }).returning(); + for (const subjectId of audience) await db.insert(connectionGrantMembers).values({ companyId: targetCompanyId, grantId: grant.id, subjectType: "user", subjectId }); + return secret.id; + } + const personal = await connection("personal", "user", "alice"); + const shared = await connection("shared", "organization", null); + const otherPerson = await connection("other-person", "user", "bob"); + const restricted = await connection("restricted", "organization", null, ["bob"]); + const crossCompany = await connection("cross-company", "organization", null, [], otherCompany.id); + const broken = await connection("broken", "organization", null); + tokenRepos.set(personal, [{ id: 10, full_name: "org/common" }, { id: 11, full_name: "alice/private" }]); + tokenRepos.set(shared, [{ id: 10, full_name: "org/common" }, { id: 12, full_name: "org/shared" }]); + const request = vi.fn(async (_url, init) => { + const token = new Headers(init?.headers).get("authorization")?.replace("Bearer ", "") ?? ""; + if (token === broken) return new Response("secret-provider-error", { status: 503 }); + expect([otherPerson, restricted, crossCompany]).not.toContain(token); + return Response.json(tokenRepos.get(token) ?? []); + }); + vi.stubGlobal("fetch", request); + const result = await toolAccessService(db).listProjectRepositories(companyId, "alice"); + expect(result.connectionCount).toBe(3); + expect(result.failedConnectionCount).toBe(1); + expect(result.repositories.map((repo) => repo.id).sort()).toEqual(["10", "11", "12"]); + expect(result.repositories.find((repo) => repo.id === "10")?.connections.sort()).toEqual(["personal", "shared"]); + expect(JSON.stringify(result)).not.toContain("secret-provider-error"); + expect(request).toHaveBeenCalledTimes(3); + const revokedMembership = await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.principalId, "alice")).returning(); + expect(revokedMembership).toHaveLength(1); + expect((await toolAccessService(db).listProjectRepositories(companyId, "alice")).repositories).toEqual([]); + expect(request).toHaveBeenCalledTimes(3); + }); + + it("rolls back the project if a later repository insert fails", async () => { + const svc = projectService(db); + await expect(svc.createWithRepositories(companyId, { name: "Rollback" }, [repo("7"), { ...repo("8"), fullName: "invalid" + String.fromCharCode(0) + "name" }])).rejects.toThrow(); + expect(await db.select().from(projectTable).where(eq(projectTable.name, "Rollback"))).toEqual([]); + }); +}); diff --git a/server/src/__tests__/project-repositories.test.ts b/server/src/__tests__/project-repositories.test.ts new file mode 100644 index 0000000000..8624df6343 --- /dev/null +++ b/server/src/__tests__/project-repositories.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { canBrowseProjectRepositoryGrant, mergeProjectRepository, resolveProjectRepositorySelection } from "../services/project-repositories.js"; +import { loadGitHubTokenRepositories } from "../services/tool-access.js"; +import type { ProjectRepository } from "@paperclipai/shared"; + +describe("project repository access", () => { + const own = { status: "active", kind: "user", subjectUserId: "alice" }; + const shared = { ...own, kind: "organization", subjectUserId: null }; + const allowed = (grant: { status: string; kind: string; subjectUserId: string | null } = own, userId: string | null = "alice", activeMember = true, audience: string[] = []) => + canBrowseProjectRepositoryGrant({ grant, userId, activeMember, audience }); + it("includes only the caller's personal identity and active company membership", () => { + expect(allowed()).toBe(true); + expect(allowed(own, "bob")).toBe(false); + expect(allowed(own, null)).toBe(false); + expect(allowed(own, "alice", false)).toBe(false); + expect(allowed({ ...own, kind: "agent" })).toBe(false); + for (const status of ["revoked", "expired", "needs_reauthorization"]) expect(allowed({ ...own, status })).toBe(false); + }); + it("honors shared audiences without an administrator bypass", () => { + expect(allowed(shared)).toBe(true); + expect(allowed(shared, "alice", true, ["alice"])).toBe(true); + expect(allowed(shared, "alice", true, ["bob"])).toBe(false); + expect(allowed(shared, "alice", false)).toBe(false); + expect(allowed(shared, null, false)).toBe(true); // local trusted board + expect(allowed(shared, null, false, ["alice"])).toBe(false); + }); + it("deduplicates by provider id across personal and shared connections", () => { + const repos = new Map(); + mergeProjectRepository(repos, { id: "10", fullName: "org/old", private: true }, "Personal"); + mergeProjectRepository(repos, { id: "10", fullName: "org/renamed", private: true }, "Company"); + mergeProjectRepository(repos, { id: "10", fullName: "org/renamed", private: true }, "Company"); + expect([...repos.values()]).toEqual([{ id: "10", fullName: "org/renamed", url: "https://github.com/org/renamed", private: true, connections: ["Personal", "Company"] }]); + }); + it("refreshes retained selections from discovery, falls back only for unavailable existing IDs, and rejects new unavailable IDs", () => { + const existing = [{ name: "old/name", repoUrl: "https://github.com/old/name", metadata: { githubRepositoryId: "1" } }]; + const renamed = { id: "1", fullName: "new/name", url: "https://github.com/new/name", connections: ["Personal"] }; + expect(resolveProjectRepositorySelection(["1", "1"], [renamed], existing)).toEqual([renamed]); + expect(resolveProjectRepositorySelection(["1"], [], existing)).toEqual([{ id: "1", fullName: "old/name", url: existing[0].repoUrl, connections: [] }]); + expect(() => resolveProjectRepositorySelection(["2"], [], existing)).toThrow("no longer available"); + }); + it("loads every PAT repository page and never follows provider-supplied URLs", async () => { + const request = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify([{ id: 1, full_name: "org/a" }]), { headers: { link: '; rel="next"' } })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: 2, full_name: "org/b", private: true }]))); + expect(await loadGitHubTokenRepositories({ Authorization: "Bearer fixture" }, request)).toEqual([{ id: "1", fullName: "org/a" }, { id: "2", fullName: "org/b", private: true }]); + expect(request.mock.calls.map(([url]) => String(url))).toEqual([ + "https://api.github.com/user/repos?per_page=100&page=1", "https://api.github.com/user/repos?per_page=100&page=2", + ]); + }); + it("surfaces failed or invalid provider responses without exposing their body", async () => { + for (const response of [new Response("secret", { status: 401 }), new Response(JSON.stringify([{ id: 1, full_name: "../bad" }]))]) { + await expect(loadGitHubTokenRepositories({}, vi.fn().mockResolvedValue(response))).rejects.toThrow(/GitHub/); + } + }); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a1bc5e9b0d..e08fff3636 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -858,6 +858,8 @@ const BOARD_ONLY_PREFIXES = [ ]; const BOARD_ONLY_OPERATIONS = new Set([ + "GET /api/companies/{companyId}/project-repositories", + "PUT /api/projects/{id}/repositories", "DELETE /api/issues/{id}/documents/{key}", "GET /api/companies/{companyId}/decisions", "GET /api/cloud/stacks", @@ -2830,6 +2832,33 @@ registry.registerPath({ // ─── Projects ──────────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/project-repositories", + tags: ["projects"], + summary: "Discover GitHub repositories available to the current board user", + description: "Deduplicates repositories across usable personal and company-shared GitHub connections. Failed connections are reported without discarding successful results.", + request: { params: z.object({ companyId: z.string() }) }, + responses: { + 200: r.ok(z.object({ + repositories: z.array(z.object({ id: z.string(), fullName: z.string(), url: z.string(), private: z.boolean().optional(), connections: z.array(z.string()) })), + connectionCount: z.number().int().nonnegative(), + failedConnectionCount: z.number().int().nonnegative(), + })), + 401: r.unauthorized, 403: r.forbidden, + }, +}); + +registry.registerPath({ + method: "put", + path: "/api/projects/{id}/repositories", + tags: ["projects"], + summary: "Replace selected GitHub source repositories", + description: "Saves provider IDs transactionally, refreshes canonical names and URLs, and preserves legacy workspace URLs. Unavailable existing selections can remain; new selections must be available to the caller.", + request: { params: z.object({ id: z.string() }), body: jsonBody(createProjectSchema.pick({ repositoryIds: true }).required()) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/projects", @@ -2844,11 +2873,12 @@ registry.registerPath({ path: "/api/companies/{companyId}/projects", tags: ["projects"], summary: "Create a project", + description: "The optional repositoryIds field selects GitHub source repositories and requires a board caller. It cannot be combined with workspace. All selections are validated before the project and repository workspaces are created atomically.", request: { params: z.object({ companyId: z.string() }), body: jsonBody(createProjectSchema), }, - responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, }); registry.registerPath({ diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 034386c549..f3226c7d5c 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,3 +1,6 @@ +import { z } from "zod"; +import { resolveProjectRepositorySelection } from "../services/project-repositories.js"; +import { toolAccessService } from "../services/tool-access.js"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; import { @@ -17,7 +20,7 @@ import { accessService, projectService, logActivity, workspaceOperationService } import { conflict, forbidden, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; -import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; import { buildWorkspaceRuntimeDesiredStatePatch, listConfiguredRuntimeServiceEntries, @@ -43,6 +46,13 @@ const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]); export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + + async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) { + assertBoard(req); + if (!ids.length) return []; + const available = await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit"); + return resolveProjectRepositorySelection(ids, available.repositories, existing); + } const access = accessService(db); const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); @@ -162,6 +172,28 @@ export function projectRoutes(db: Db) { } }); + router.get("/companies/:companyId/project-repositories", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit")); + }); + + router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => { + assertBoard(req); + const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found"); + if (!project) return; + const repositories = await selectedRepositories(req, project.companyId, req.body.repositoryIds, project.workspaces); + const updated = await svc.replaceRepositories(project.id, repositories); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId, + action: "project.repositories_updated", entityType: "project", entityId: project.id, + details: { repositoryIds: repositories.map((repo) => repo.id) }, + }); + res.json(updated); + }); + router.get("/companies/:companyId/projects", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -191,9 +223,10 @@ export function projectRoutes(db: Db) { assertCompanyAccess(req, companyId); type CreateProjectPayload = Parameters[1] & { workspace?: Parameters[1]; + repositoryIds?: string[]; }; - const { workspace, ...projectData } = req.body as CreateProjectPayload; + const { workspace, repositoryIds, ...projectData } = req.body as CreateProjectPayload; await assertProjectEnvironmentSelection( companyId, readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy), @@ -213,7 +246,9 @@ export function projectRoutes(db: Db) { { strictMode: strictSecretsMode, fieldPath: "env" }, ); } - const project = await svc.create(companyId, projectData); + if (workspace && repositoryIds) throw unprocessable("Use either workspace or repositoryIds when creating a project"); + const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null; + const project = repositories ? await svc.createWithRepositories(companyId, projectData, repositories) : await svc.create(companyId, projectData); if (project.env) { await secretsSvc.syncEnvBindingsForTarget?.( companyId, diff --git a/server/src/services/project-repositories.ts b/server/src/services/project-repositories.ts new file mode 100644 index 0000000000..b943dae589 --- /dev/null +++ b/server/src/services/project-repositories.ts @@ -0,0 +1,42 @@ +import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared"; +import { unprocessable } from "../errors.js"; +import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js"; + +export function canBrowseProjectRepositoryGrant(input: { + grant: { status: string; kind: string; subjectUserId: string | null }; + userId: string | null; + activeMember: boolean; + audience: string[]; +}) { + const { grant, userId, activeMember, audience } = input; + if (grant.status !== "active") return false; + if (grant.kind === "user") return Boolean(userId && activeMember && grant.subjectUserId === userId); + return grant.kind === "organization" && isConnectionGrantAudienceAllowed(audience, userId, activeMember); +} + +export function mergeProjectRepository( + repositories: Map, + repo: { id: string; fullName: string; private?: boolean }, + connectionName: string, +) { + const previous = repositories.get(repo.id); + repositories.set(repo.id, { + ...repo, url: `https://github.com/${repo.fullName}`, + connections: [...new Set([...(previous?.connections ?? []), connectionName])], + }); +} + +/** Prefer current provider metadata; unavailable existing selections can remain. */ +export function resolveProjectRepositorySelection( + ids: string[], + available: ProjectRepository[], + existing: Pick[] = [], +): ProjectRepository[] { + return [...new Set(ids)].map((id) => { + const current = available.find((repo) => repo.id === id); + if (current) return current; + const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl); + if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [] }; + throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again."); + }); +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 0f193a98a7..e11056dc38 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -811,6 +811,52 @@ export function projectService(db: Db) { }; }, + createWithRepositories: async (companyId: string, data: Parameters[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise => { + return db.transaction(async (tx) => { + const service = projectService(tx as unknown as Db); + const project = await service.create(companyId, data); + for (const repo of repositories) { + await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } }); + } + return (await service.getById(project.id))!; + }); + }, + + replaceRepositories: async (projectId: string, repositories: import("@paperclipai/shared").ProjectRepository[]): Promise => { + return db.transaction(async (tx) => { + const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update"); + if (!project) return null; + const service = projectService(tx as unknown as Db); + const existing = await service.listWorkspaces(projectId); + const ids = new Set(repositories.map((repo) => repo.id)); + for (const workspace of existing) { + const repoId = workspace.metadata?.githubRepositoryId; + if (typeof repoId === "string" && !ids.has(repoId)) { + // Keep local/runtime workspace configuration when detaching source. + if (workspace.cwd || workspace.remoteWorkspaceRef) { + const { githubRepositoryId: _id, ...metadata } = workspace.metadata!; + await service.updateWorkspace(projectId, workspace.id, { repoUrl: null, metadata }); + } else await service.removeWorkspace(projectId, workspace.id); + } + } + for (const repo of repositories) { + const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id); + if (retained) { + if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) { + await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url }); + } + continue; + } + const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase()); + if (legacy) { + await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id } }); + } else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } }); + } + await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId)); + return service.getById(projectId); + }); + }, + create: createProject, update: async ( diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index b3f91e747e..a642acf6bb 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,4 @@ +import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js"; import { captureRunIdentity } from "./run-identity.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -1930,6 +1931,27 @@ function managedConnectorProfile(value: string | undefined): { return null; } +export async function loadGitHubTokenRepositories(headers: Record, request: typeof fetch = fetch) { + const repositories: Array<{ id: string; fullName: string; private?: boolean }> = []; + for (let page = 1; ; page += 1) { + const response = await request(`https://api.github.com/user/repos?per_page=100&page=${page}`, { + headers: { ...headers, accept: "application/vnd.github+json", "user-agent": "Paperclip", "x-github-api-version": "2022-11-28" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw unprocessable("Could not load GitHub repositories. Reconnect GitHub and try again."); + const rows: unknown = await response.json(); + if (!Array.isArray(rows)) throw unprocessable("GitHub returned invalid repositories"); + for (const row of rows) { + if (!recordValue(row) || !githubId(row.id) || typeof row.full_name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(row.full_name)) { + throw unprocessable("GitHub returned invalid repository metadata"); + } + repositories.push({ id: githubId(row.id)!, fullName: row.full_name, ...(typeof row.private === "boolean" ? { private: row.private } : {}) }); + } + if (!/;\s*rel="next"/.test(response.headers.get("link") ?? "")) return repositories; + if (!rows.length) throw unprocessable("GitHub returned invalid pagination"); + } +} + export async function loadGitHubGrantMetadata( accessToken: string, request: typeof fetch = fetch, @@ -2014,7 +2036,7 @@ export async function loadGitHubGrantMetadata( for (const repository of await list(`/user/installations/${installationId}/repositories`, "repositories")) { const id = githubId(repository.id); const fullName = typeof repository.full_name === "string" ? repository.full_name : ""; - if (!id || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fullName)) { + if (!id || !/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(fullName)) { throw unprocessable("GitHub returned invalid repository metadata", { code: "github_bad_response" }); } repositories.set(id, { @@ -12374,6 +12396,70 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return toApplication(row); }, + // Repository discovery uses credential audiences, not connection-management + // visibility. An administrator cannot browse another user's private repos. + listProjectRepositories: async (companyId: string, userId: string | null, localTrusted = false) => { + const [connections, grants, members, memberships] = await Promise.all([ + db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.enabled, true))), + db.select().from(connectionGrants).where(eq(connectionGrants.companyId, companyId)), + db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)), + userId ? db.select().from(companyMemberships).where(and( + eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), + )) : Promise.resolve([]), + ]); + const repositories = new Map(); + let connectionCount = 0; + let failedConnectionCount = 0; + for (const connection of connections) { + if (connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") continue; + const connectionGrants = grants.filter((grant) => grant.connectionId === connection.id); + const availableGrants = connectionGrants.filter((grant) => + !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) + && canBrowseProjectRepositoryGrant({ + grant, userId, activeMember: localTrusted || memberships.length > 0, + audience: members.filter((member) => member.grantId === grant.id).map((member) => member.subjectId), + })); + // Legacy shared PAT connections predate grants. Never fall back when a + // grant exists but is revoked, private, or outside the caller's audience. + const legacyShared = connectionGrants.length === 0 && connection.credentialPolicy === "shared" + && (localTrusted || !!userId && memberships.length > 0); + if (!availableGrants.length && !legacyShared) continue; + connectionCount += 1; + const actor: ActorInfo = { actorType: "user", actorId: userId ?? "board" }; + let failed = false; + for (const initialGrant of legacyShared ? [null] : availableGrants) { + try { + let rows: Array<{ id: string; fullName: string; private?: boolean }>; + if (initialGrant && asRecord(asRecord(connection.config).oauth).connectorProfile === "github.code") { + const grant = await refreshManagedGitHubGrantAccess(connection, initialGrant, actor); + rows = grant.providerTenant?.github?.repositories ?? []; + } else { + const headers = initialGrant + ? await (async () => { + const ref = initialGrant.credentialSecretRefs.find((ref) => + ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath)); + if (!ref) throw unprocessable("Reconnect GitHub to load repositories"); + const secret = await resolveOAuthGrantSecret(connection, initialGrant, ref, actor, undefined); + return { Authorization: `Bearer ${secret.value}` }; + })() + : await resolveCredentialHeaders(connection, actor); + rows = await loadGitHubTokenRepositories(headers); + } + for (const row of rows) { + mergeProjectRepository(repositories, row, connection.name); + } + } catch { + // Credential/provider errors may contain secrets. Only expose an + // aggregate failure; successful connections remain usable. + failed = true; + } + } + if (failed) failedConnectionCount += 1; + } + return { repositories: [...repositories.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)), connectionCount, failedConnectionCount }; + }, + listConnections: async (companyId: string): Promise => { const rows = await db .select() diff --git a/tests/e2e/project-repositories.spec.ts b/tests/e2e/project-repositories.spec.ts new file mode 100644 index 0000000000..8b9cae3167 --- /dev/null +++ b/tests/e2e/project-repositories.spec.ts @@ -0,0 +1,84 @@ +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); +}); diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index af1c736a52..32f296e31c 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,5 +1,6 @@ import type { Project, + ProjectRepositoryOptions, ProjectWorkspace, WorkspaceOperation, WorkspaceRuntimeControlTarget, @@ -18,6 +19,8 @@ function projectPath(id: string, companyId?: string, suffix = "") { } export const projectsApi = { + repositoryOptions: (companyId: string) => api.get(`/companies/${companyId}/project-repositories`), + setRepositories: (id: string, repositoryIds: string[]) => api.put(projectPath(id, undefined, "/repositories"), { repositoryIds }), list: (companyId: string, opts: { includeArchived?: boolean } = {}) => { const params = new URLSearchParams(); if (opts.includeArchived) params.set("includeArchived", "true"); diff --git a/ui/src/components/LegacyProjectRepository.tsx b/ui/src/components/LegacyProjectRepository.tsx new file mode 100644 index 0000000000..ad79bd8fa4 --- /dev/null +++ b/ui/src/components/LegacyProjectRepository.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ProjectWorkspace } from "@paperclipai/shared"; +import { Link } from "lucide-react"; +import { projectsApi } from "@/api/projects"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +/** Preserve manual URL editing for workspaces created before the GitHub picker. */ +export function LegacyProjectRepository({ workspace, projectRef }: { workspace: ProjectWorkspace; projectRef: string }) { + const client = useQueryClient(); + const [draft, setDraft] = useState(null); + const save = useMutation({ + mutationFn: async () => { + const repoUrl = draft?.trim() || null; + if (!repoUrl && !workspace.cwd && !workspace.remoteWorkspaceRef) return projectsApi.removeWorkspace(workspace.projectId, workspace.id); + return projectsApi.updateWorkspace(workspace.projectId, workspace.id, { repoUrl }); + }, + onSuccess: () => { + void client.invalidateQueries({ queryKey: queryKeys.projects.all(workspace.companyId) }); + void client.invalidateQueries({ queryKey: queryKeys.projects.detail(workspace.projectId) }); + void client.invalidateQueries({ queryKey: queryKeys.projects.detail(projectRef) }); + setDraft(null); + }, + }); + return
+ Existing repo URL + {draft === null ?
+ + {workspace.repoUrl} + +
:
{ event.preventDefault(); if (!save.isPending) save.mutate(); }}> + setDraft(event.target.value)} /> + {save.isError &&

{save.error.message}

} +
+
} +
; +} diff --git a/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx b/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx index 270d524f0d..3e573ce943 100644 --- a/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx +++ b/ui/src/components/NewProjectDialog.managed-sandbox.test.tsx @@ -16,7 +16,10 @@ function act(callback: () => void) { }); } -vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), createWorkspace: vi.fn() } })); +vi.mock("../api/projects", () => ({ projectsApi: { + create: vi.fn(), + repositoryOptions: vi.fn().mockResolvedValue({ repositories: [], connectionCount: 0, failedConnectionCount: 0 }), +} })); vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } })); vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } })); vi.mock("../api/access", () => ({ accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) } })); @@ -80,38 +83,23 @@ function localPathInput() { return document.body.querySelector('input[placeholder="/absolute/path/to/workspace"]'); } -describe("NewProjectDialog — local folder under the managed-sandbox-only policy", () => { - it("offers the local folder field and its picker when the policy is off", () => { - render({}); - - expect(documentText()).toContain("Local folder"); - expect(localPathInput()).not.toBeNull(); - const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter( - (button) => button.textContent?.trim() === "Choose", - ); - expect(chooseButtons.length).toBeGreaterThan(0); - }); - - it("keeps the local folder field 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 field. - render(null); - - expect(documentText()).not.toContain("Local folder"); - expect(localPathInput()).toBeNull(); - expect(documentText()).toContain("Repo URL"); - }); - - it("hides the local folder field and its picker when the policy is on", () => { - render({ enableManagedSandboxOnly: true }); +describe("NewProjectDialog — source repositories across host policies", () => { + it.each([ + ["policy off", {}], + ["policy unresolved", null], + ["managed sandbox only", { enableManagedSandboxOnly: true }], + ] as const)("offers the simplified project form with %s", (_label, settings) => { + render(settings); + expect(documentText()).toContain("Source repos"); + expect(documentText()).toContain("Add GitHub repo"); + expect(documentText()).not.toContain("Repo URL"); expect(documentText()).not.toContain("Local folder"); expect(localPathInput()).toBeNull(); + expect(document.body.querySelector('input[placeholder="Project name"]')).not.toBeNull(); const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter( (button) => button.textContent?.trim() === "Choose", ); expect(chooseButtons).toHaveLength(0); - // The repo field is unrelated to the host filesystem, so it stays. - expect(documentText()).toContain("Repo URL"); }); }); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 90efd3ae70..80813664b4 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -1,457 +1,67 @@ -import { useMemo, useRef, useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ProjectRepository } from "@paperclipai/shared"; +import { Folder, X } from "lucide-react"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; -import { accessApi } from "../api/access"; import { projectsApi } from "../api/projects"; -import { agentsApi } from "../api/agents"; -import { goalsApi } from "../api/goals"; -import { assetsApi } from "../api/assets"; -import { buildMarkdownMentionOptions } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; -import { - Dialog, - DialogContent, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Maximize2, - Minimize2, - Target, - Calendar, - Plus, - X, - HelpCircle, -} from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "../lib/utils"; -import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; -import { StatusBadge } from "./StatusBadge"; -import { ChoosePathButton } from "./PathInstructionsModal"; -import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly"; - -const projectStatuses = [ - { value: "backlog", label: "Backlog" }, - { value: "planned", label: "Planned" }, - { value: "in_progress", label: "In Progress" }, - { value: "completed", label: "Completed" }, - { value: "cancelled", label: "Cancelled" }, -]; +import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; +import { Button } from "./ui/button"; +import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; export function NewProjectDialog() { const { newProjectOpen, closeNewProject } = useDialog(); - const { selectedCompanyId, selectedCompany } = useCompany(); - const queryClient = useQueryClient(); - const { hideHostPaths } = useManagedSandboxOnly(); + const { selectedCompanyId } = useCompany(); + return selectedCompanyId && newProjectOpen + ? : null; +} + +export function NewProjectForm({ companyId, onClose }: { companyId: string; onClose: () => void }) { + const client = useQueryClient(); const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [status, setStatus] = useState("planned"); - const [goalIds, setGoalIds] = useState([]); - const [targetDate, setTargetDate] = useState(""); - const [expanded, setExpanded] = useState(false); - const [workspaceLocalPath, setWorkspaceLocalPath] = useState(""); - const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); - const [workspaceError, setWorkspaceError] = useState(null); - - const [statusOpen, setStatusOpen] = useState(false); - const [goalOpen, setGoalOpen] = useState(false); - const descriptionEditorRef = useRef(null); - - const { data: goals } = useQuery({ - queryKey: queryKeys.goals.list(selectedCompanyId!), - queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && newProjectOpen, - }); - - const { data: agents } = useQuery({ - queryKey: queryKeys.agents.list(selectedCompanyId!), - queryFn: () => agentsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId && newProjectOpen, - }); - - const { data: companyMembers } = useQuery({ - queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), - queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), - enabled: !!selectedCompanyId && newProjectOpen, - }); - - const mentionOptions = useMemo(() => { - return buildMarkdownMentionOptions({ - agents, - members: companyMembers?.users, - }); - }, [agents, companyMembers?.users]); - - const createProject = useMutation({ - mutationFn: (data: Record) => - projectsApi.create(selectedCompanyId!, data), - }); - - const uploadDescriptionImage = useMutation({ - mutationFn: async (file: File) => { - if (!selectedCompanyId) throw new Error("No organization selected"); - return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts"); + const [repos, setRepos] = useState([]); + const [connecting, setConnecting] = useState(false); + const input = useRef(null); + const create = useMutation({ + mutationFn: () => projectsApi.create(companyId, { name: name.trim(), status: "planned", repositoryIds: repos.map((repo) => repo.id) }), + onSuccess: () => { + void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) }); + onClose(); }, }); - - function reset() { - setName(""); - setDescription(""); - setStatus("planned"); - setGoalIds([]); - setTargetDate(""); - setExpanded(false); - setWorkspaceLocalPath(""); - setWorkspaceRepoUrl(""); - setWorkspaceError(null); - } - - const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); - - const looksLikeRepoUrl = (value: string) => { - try { - const parsed = new URL(value); - if (parsed.protocol !== "https:") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2; - } catch { - return false; - } - }; - - const deriveWorkspaceNameFromPath = (value: string) => { - const normalized = value.trim().replace(/[\\/]+$/, ""); - const segments = normalized.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] ?? "Local folder"; - }; - - const deriveWorkspaceNameFromRepo = (value: string) => { - try { - const parsed = new URL(value); - const segments = parsed.pathname.split("/").filter(Boolean); - const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? ""; - return repo || "GitHub repo"; - } catch { - return "GitHub repo"; - } - }; - - async function handleSubmit() { - if (!selectedCompanyId || !name.trim()) return; - const localPath = workspaceLocalPath.trim(); - const repoUrl = workspaceRepoUrl.trim(); - - if (localPath && !isAbsolutePath(localPath)) { - setWorkspaceError("Local folder must be a full absolute path."); - return; - } - if (repoUrl && !looksLikeRepoUrl(repoUrl)) { - setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL."); - return; - } - - setWorkspaceError(null); - - try { - const created = await createProject.mutateAsync({ - name: name.trim(), - description: description.trim() || undefined, - status, - // No color is sent — new projects persist color = null (neutral gray). See PAP-68. - ...(goalIds.length > 0 ? { goalIds } : {}), - ...(targetDate ? { targetDate } : {}), - }); - - if (localPath || repoUrl) { - const workspacePayload: Record = { - name: localPath - ? deriveWorkspaceNameFromPath(localPath) - : deriveWorkspaceNameFromRepo(repoUrl), - ...(localPath ? { cwd: localPath } : {}), - ...(repoUrl ? { repoUrl } : {}), - }; - await projectsApi.createWorkspace(created.id, workspacePayload); - } - - queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) }); - reset(); - closeNewProject(); - } catch { - // surface through createProject.isError - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleSubmit(); - } - } - - const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id)); - const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id)); - - return ( - { - if (!open) { - reset(); - closeNewProject(); - } - }} - > - - {/* Header */} -
-
- {selectedCompany && ( - - {selectedCompany.issuePrefix} - - )} - - New project + return { if (!open && !create.isPending) onClose(); }}> + { event.preventDefault(); input.current?.focus(); }}> + {connecting ?
+ Connect GitHub + setConnecting(false)} onComplete={() => { + void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) }); + setConnecting(false); + }} /> +
:
{ event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}> +
+
+ Create project +
-
- - +
+
- - {/* Name */} -
- setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Tab" && !e.shiftKey) { - e.preventDefault(); - descriptionEditorRef.current?.focus(); - } - }} - autoFocus - /> +
+ setConnecting(true)} disabled={create.isPending} />
- - {/* Description */} -
- { - const asset = await uploadDescriptionImage.mutateAsync(file); - return asset.contentPath; - }} - /> + {create.isError &&

{create.error.message}

} +
+ +
- -
-
-
- - optional - - - - - - Link a GitHub repository so agents can clone, read, and push code for this project. - - -
- { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }} - placeholder="https://github.com/org/repo" - /> -
- - {/* - The local folder is an absolute path on the execution host. Under - the managed-sandbox-only policy every agent runs in the - platform-managed environment, so the field and its folder picker - never render and the create request carries no cwd. The field also - stays hidden until the policy is known. - */} - {!hideHostPaths && ( -
-
- - optional - - - - - - Set an absolute path on this machine where local agents will read and write files for this project. - - -
-
- { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }} - placeholder="/absolute/path/to/workspace" - /> - -
-
- )} - - {workspaceError && ( -

{workspaceError}

- )} -
- - {/* Property chips */} -
- {/* Status */} - - - - - - {projectStatuses.map((s) => ( - - ))} - - - - {selectedGoals.map((goal) => ( - - - {goal.title} - - - ))} - - - - - - - {selectedGoals.length === 0 && ( - - )} - {availableGoals.map((g) => ( - - ))} - {selectedGoals.length > 0 && availableGoals.length === 0 && ( -
- All goals already selected. -
- )} -
-
- - {/* Target date */} -
- - setTargetDate(e.target.value)} - placeholder="Target date" - /> -
-
- - {/* Footer */} -
- {createProject.isError ? ( -

Failed to create project.

- ) : ( - - )} - -
- -
- ); + } + +
; } diff --git a/ui/src/components/ProjectProperties.managed-sandbox.test.tsx b/ui/src/components/ProjectProperties.managed-sandbox.test.tsx index cbb90eb880..703900a8e0 100644 --- a/ui/src/components/ProjectProperties.managed-sandbox.test.tsx +++ b/ui/src/components/ProjectProperties.managed-sandbox.test.tsx @@ -125,10 +125,10 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli 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("Repo"); + expect(container.textContent).toContain("Source repos"); }); - it("keeps only the managed-folder label for a managed checkout when the policy is on", () => { + it("omits the host codebase section for a managed checkout when the policy is on", () => { render( makeProject(makeCodebase({ localFolder: null, @@ -138,7 +138,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli { enableIsolatedWorkspaces: true, enableManagedSandboxOnly: true }, ); - expect(container.textContent).toContain("Paperclip-managed folder."); + 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"); @@ -151,7 +151,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli expect(container.textContent).not.toContain("Local folder"); expect(container.textContent).not.toContain(LOCAL_FOLDER); - expect(container.textContent).toContain("Repo"); + expect(container.textContent).toContain("Source repos"); }); it("never opens the absolute-path edit panel when the policy is on", () => { diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 644ce56fa1..fad8b803a6 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -1,24 +1,20 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment"; import { Link } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared"; -import { StatusBadge } from "./StatusBadge"; +import { ProjectRepositories } from "./ProjectRepositories"; import { cn, formatDate } from "../lib/utils"; import { environmentsApi } from "../api/environments"; -import { goalsApi } from "../api/goals"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { secretsApi } from "../api/secrets"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; -import { statusBadge, statusBadgeDefault } from "../lib/status-colors"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Plus, Trash2, X } from "lucide-react"; -import { GithubIcon } from "@/components/icons/github-icon"; +import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { DraftInput } from "./agent-config-primitives"; @@ -26,16 +22,9 @@ import { InlineEditor } from "./InlineEditor"; import { EnvironmentVariablesEditor } from "./environment-variables-editor"; import { Badge } from "@/components/ui/badge"; -const PROJECT_STATUSES = [ - { value: "backlog", label: "Backlog" }, - { value: "planned", label: "Planned" }, - { value: "in_progress", label: "In Progress" }, - { value: "completed", label: "Completed" }, - { value: "cancelled", label: "Cancelled" }, -]; - interface ProjectPropertiesProps { project: Project; + repositories?: ReactNode; onUpdate?: (data: Record) => void; onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record) => void; getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState; @@ -147,42 +136,6 @@ function PropertyRow({ ); } -function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (status: string) => void }) { - const [open, setOpen] = useState(false); - const colorClass = statusBadge[status] ?? statusBadgeDefault; - - return ( - - - - - - {PROJECT_STATUSES.map((s) => ( - - ))} - - - ); -} - function ArchiveDangerZone({ project, onArchive, @@ -248,14 +201,12 @@ function ArchiveDangerZone({ ); } -export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { +export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); - const [goalOpen, setGoalOpen] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); - const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null); + const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); - const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); const commitField = (field: ProjectConfigFieldKey, data: Record) => { @@ -267,11 +218,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa }; const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle"; - const { data: allGoals } = useQuery({ - queryKey: queryKeys.goals.list(selectedCompanyId!), - queryFn: () => goalsApi.list(selectedCompanyId!), - enabled: !!selectedCompanyId, - }); const { data: experimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), @@ -307,24 +253,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa enabled: !!selectedCompanyId && environmentsEnabled, }); - const linkedGoalIds = project.goalIds.length > 0 - ? project.goalIds - : project.goalId - ? [project.goalId] - : []; - - const linkedGoals = project.goals.length > 0 - ? project.goals - : linkedGoalIds.map((id) => ({ - id, - title: allGoals?.find((g) => g.id === id)?.title ?? id.slice(0, 8), - })); - - const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id)); const workspaces = project.workspaces ?? []; const codebase = project.codebase; const primaryCodebaseWorkspace = project.primaryWorkspace ?? null; - const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id); + const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId); const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null; const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; @@ -373,7 +305,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa mutationFn: (data: Record) => projectsApi.createWorkspace(project.id, data), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); @@ -384,7 +315,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); @@ -395,24 +325,12 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa projectsApi.updateWorkspace(project.id, workspaceId, data), onSuccess: () => { setWorkspaceCwd(""); - setWorkspaceRepoUrl(""); setWorkspaceMode(null); setWorkspaceError(null); invalidateProject(); }, }); - const removeGoal = (goalId: string) => { - if (!onUpdate && !onFieldUpdate) return; - commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) }); - }; - - const addGoal = (goalId: string) => { - if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return; - commitField("goals", { goalIds: [...linkedGoalIds, goalId] }); - setGoalOpen(false); - }; - const updateExecutionWorkspacePolicy = (patch: Record) => { if (!onUpdate && !onFieldUpdate) return; return { @@ -428,17 +346,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); - const looksLikeRepoUrl = (value: string) => { - try { - const parsed = new URL(value); - if (parsed.protocol !== "https:") return false; - const segments = parsed.pathname.split("/").filter(Boolean); - return segments.length >= 2; - } catch { - return false; - } - }; - const isSafeExternalUrl = (value: string | null | undefined) => { if (!value) return false; try { @@ -449,20 +356,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa } }; - const formatRepoUrl = (value: string) => { - try { - const parsed = new URL(value); - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length < 2) return parsed.host; - const owner = segments[0]; - const repo = segments[1]?.replace(/\.git$/i, ""); - if (!owner || !repo) return parsed.host; - return `${parsed.host}/${owner}/${repo}`; - } catch { - return value; - } - }; - const deriveSourceType = (cwd: string | null, repoUrl: string | null) => { if (repoUrl) return "git_repo"; if (cwd) return "local_path"; @@ -509,21 +402,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa persistCodebase({ cwd }); }; - const submitRepoWorkspace = () => { - const repoUrl = workspaceRepoUrl.trim(); - if (!repoUrl) { - setWorkspaceError(null); - persistCodebase({ repoUrl: null }); - return; - } - if (!looksLikeRepoUrl(repoUrl)) { - setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL."); - return; - } - setWorkspaceError(null); - persistCodebase({ repoUrl }); - }; - const clearLocalWorkspace = () => { const confirmed = window.confirm( codebase.repoUrl @@ -534,24 +412,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa persistCodebase({ cwd: null }); }; - const clearRepoWorkspace = () => { - const hasLocalFolder = Boolean(codebase.localFolder); - const confirmed = window.confirm( - hasLocalFolder - ? "Clear repo from this workspace?" - : "Delete this workspace repo?", - ); - if (!confirmed) return; - if (primaryCodebaseWorkspace && hasLocalFolder) { - updateWorkspace.mutate({ - workspaceId: primaryCodebaseWorkspace.id, - data: { repoUrl: null, repoRef: null, defaultRef: null, sourceType: deriveSourceType(codebase.localFolder, null) }, - }); - return; - } - persistCodebase({ repoUrl: null }); - }; - return (
@@ -589,83 +449,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa

)} - }> - {onUpdate || onFieldUpdate ? ( - commitField("status", { status })} - /> - ) : ( - - )} - - {project.leadAgentId && ( - - {project.leadAgentId.slice(0, 8)} - - )} - } - alignStart - valueClassName="space-y-2" - > - {linkedGoals.length > 0 && ( -
- {linkedGoals.map((goal) => ( - - - {goal.title} - - {(onUpdate || onFieldUpdate) && ( - - )} - - ))} -
- )} - {(onUpdate || onFieldUpdate) && ( - - - - - - {availableGoals.length === 0 ? ( -
- All goals linked. -
- ) : ( - availableGoals.map((goal) => ( - - )) - )} -
-
- )} -
+ {repositories ?? } } alignStart @@ -673,6 +457,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa >
commitField("env", { env: env ?? null })} /> -

- Applied to all runs for tasks in this project. Project values override agent env on key conflicts. -

+
- }> - {formatDate(project.createdAt)} - }> {formatDate(project.updatedAt)} @@ -703,7 +483,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
-
+ {(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) &&
Codebase @@ -724,69 +504,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
-
-
Repo
- {codebase.repoUrl ? ( -
- {isSafeExternalUrl(codebase.repoUrl) ? ( - - - {formatRepoUrl(codebase.repoUrl)} - - - ) : ( -
- - {codebase.repoUrl} -
- )} -
- - -
-
- ) : ( -
-
Not set.
- -
- )} -
- {/* The local folder is an absolute path on the execution host. Under the managed-sandbox-only policy every agent runs in the @@ -939,39 +656,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)} - {workspaceMode === "repo" && ( -
- setWorkspaceRepoUrl(e.target.value)} - placeholder="https://github.com/org/repo" - /> -
- - -
-
- )} {workspaceError && (

{workspaceError}

)} @@ -984,7 +668,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa {updateWorkspace.isError && (

Failed to update workspace.

)} -
+
} {isolatedWorkspacesEnabled ? ( <> @@ -1324,6 +1008,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)} + }> + {formatDate(project.createdAt)} +
); } diff --git a/ui/src/components/ProjectRepositories.tsx b/ui/src/components/ProjectRepositories.tsx new file mode 100644 index 0000000000..a5f0b861da --- /dev/null +++ b/ui/src/components/ProjectRepositories.tsx @@ -0,0 +1,49 @@ +import { LegacyProjectRepository } from "./LegacyProjectRepository"; +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Project, ProjectRepository } from "@paperclipai/shared"; +import { projectsApi } from "@/api/projects"; +import { queryKeys } from "@/lib/queryKeys"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput"; +import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; +import { Button } from "./ui/button"; + +export function ProjectRepositories({ project }: { project: Project }) { + const client = useQueryClient(); + const saved: ProjectRepository[] = project.workspaces.flatMap((workspace) => { + const id = workspace.metadata?.githubRepositoryId; + return typeof id === "string" && workspace.repoUrl ? [{ id, fullName: workspace.name, url: workspace.repoUrl, connections: [] }] : []; + }); + const [draft, setDraft] = useState(null); + const [connecting, setConnecting] = useState(false); + const save = useMutation({ + mutationFn: () => projectsApi.setRepositories(project.id, (draft ?? saved).map((repo) => repo.id)), + onSuccess: (updated) => { + for (const ref of new Set([project.id, project.urlKey])) { + client.setQueriesData({ queryKey: queryKeys.projects.detail(ref) }, updated); + void client.invalidateQueries({ queryKey: queryKeys.projects.detail(ref) }); + } + void client.invalidateQueries({ queryKey: queryKeys.projects.all(project.companyId) }); + void client.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); + setDraft(null); + }, + }); + return
+ { setDraft(repos); save.reset(); }} onConnect={() => setConnecting(true)} disabled={save.isPending} /> + {project.workspaces.filter((workspace) => workspace.repoUrl && !workspace.metadata?.githubRepositoryId).map((workspace) => )} +
+ {save.isError &&

{save.error.message}

} + {save.isSuccess && Changes saved} + {draft && } + +
+ + Connect GitHub + setConnecting(false)} onComplete={() => { + void client.invalidateQueries({ queryKey: repositoryOptionsKey(project.companyId) }); + setConnecting(false); + }} /> + +
; +} diff --git a/ui/src/components/ProjectRepositoryInput.tsx b/ui/src/components/ProjectRepositoryInput.tsx new file mode 100644 index 0000000000..0a58cd891f --- /dev/null +++ b/ui/src/components/ProjectRepositoryInput.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ProjectRepository } from "@paperclipai/shared"; +import { projectsApi } from "@/api/projects"; +import { RepositoryEditor } from "./RepositoryEditor"; +import { Button } from "./ui/button"; + +export const repositoryOptionsKey = (companyId: string) => ["project-repositories", companyId] as const; + +export function ProjectRepositoryInput({ companyId, selected, onChange, onConnect, disabled }: { + companyId: string; + selected: ProjectRepository[]; + onChange: (repos: ProjectRepository[]) => void; + onConnect: () => void; + disabled?: boolean; +}) { + const query = useQuery({ queryKey: repositoryOptionsKey(companyId), queryFn: () => projectsApi.repositoryOptions(companyId), staleTime: 30_000 }); + const state = query.isPending ? "loading" : query.isError ? "error" + : !query.data.connectionCount ? "disconnected" + : query.data.failedConnectionCount && !query.data.repositories.length ? "error" + : !query.data.repositories.length ? "empty" : "ready"; + return
+ query.data?.repositories.find((available) => available.id === repo.id) ?? repo)} onChange={onChange} available={query.data?.repositories} state={state} + onRetry={() => void query.refetch()} onConnect={onConnect} disabled={disabled} /> + {!!query.data?.failedConnectionCount && query.data.repositories.length > 0 &&
+ Some GitHub connections could not load. Reconnect them in Apps or try again. + +
} +
; +} diff --git a/ui/src/components/RepositoryEditor.tsx b/ui/src/components/RepositoryEditor.tsx new file mode 100644 index 0000000000..7a9c85f595 --- /dev/null +++ b/ui/src/components/RepositoryEditor.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import type { ProjectRepository } from "@paperclipai/shared"; +import { GitBranch, LockKeyhole, Plus, X } from "lucide-react"; +import { GithubIcon } from "./icons/github-icon"; +import { SearchableSelect } from "./SearchableSelect"; +import { Button } from "./ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +function RepoRow({ repo, onRemove }: { repo: ProjectRepository; onRemove: () => void }) { + return ( +
+ +
+ {repo.fullName} + {repo.connections.length > 0 && + {repo.connections.join(" · ")} + } +
+ {repo.private && } + +
+ ); +} + +/** Shared by both review surfaces, ready to extract after design approval. */ +export function RepositoryEditor({ selected, onChange, state = "ready", available = [], onRetry, onConnect, disabled = false }: { + selected: ProjectRepository[]; + onChange: (repos: ProjectRepository[]) => void; + state?: "ready" | "loading" | "disconnected" | "empty" | "error"; + available?: ProjectRepository[]; + onRetry: () => void; + disabled?: boolean; + onConnect: () => void; +}) { + const [showPicker, setShowPicker] = useState(false); + const picker = useRef(null); + useEffect(() => { + if (showPicker) picker.current?.querySelector('[role="combobox"]')?.focus(); + }, [showPicker]); + const options = available.filter((repo) => !selected.some((item) => item.id === repo.id)).map((repo) => ({ + key: repo.id, value: repo.id, label: repo.fullName, + searchText: repo.connections.join(" "), repo, + })); + const addClassName = selected.length ? "self-start" : "h-24 w-full flex-col gap-2"; + const addLabel = selected.length ? "Add another repo" : "Add GitHub repo"; + return ( +
+
Source reposoptional
+ {selected.map((repo) => onChange(selected.filter((item) => item.id !== repo.id))} />)} + {state === "disconnected" ? ( + + + +
+ +

Connect GitHub to pick a repo

Choose from repos you can access through your GitHub connections.

+ +
+
+
+ ) : showPicker ? ( +
+ + value="" groups={[{ id: "available", label: "Available GitHub repos", options }]} + placeholder={addLabel} searchPlaceholder="Search GitHub repos…" + contentClassName="max-h-(--radix-popover-content-available-height) overflow-hidden [&_[data-slot=command]]:max-h-(--radix-popover-content-available-height) [&_[data-slot=command-list]]:min-h-0 [&_[data-slot=command-list]]:flex-1 [&_[data-slot=command-input-wrapper]]:shrink-0" + loading={state === "loading"} loadingMessage="Loading GitHub repos…" + emptyMessage={state === "error" ? "Couldn’t load GitHub repos. Try again." : state === "empty" ? "No repos available. Connect an account with repo access." : "No matching repos. Try another search or connection."} + renderValue={() => {addLabel}} + renderOption={({ repo }) => <>{repo.fullName}{repo.connections.join(" · ")}{repo.private && }} + onValueChange={(_, option) => { onChange([...selected, option.repo]); setShowPicker(false); }} + createItem={state === "error" + ? { render: () => <>Try again, onSelect: () => { onRetry(); } } + : { render: () => <>Connect another GitHub account, onSelect: onConnect }} + /> +
+

{"All GitHub connections you can use."}

+ +
+
+ ) : } +
+ ); +} + diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 4647103bea..ee107c14dc 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -475,6 +475,7 @@ export interface ConnectionSetupFlowProps { serviceSlug?: string; requestedAgentId?: string; interactionId?: string; + forceNewConnection?: boolean; existingConnections?: ToolConnection[]; onUseExisting?: (connectionId: string) => Promise; onComplete?: (result: ConnectionSetupCompletion) => void; @@ -496,6 +497,7 @@ export function ConnectionSetupFlow({ serviceSlug, requestedAgentId, interactionId, + forceNewConnection = false, existingConnections = [], onUseExisting, onComplete, @@ -519,7 +521,7 @@ export function ConnectionSetupFlow({ || null; const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined; const sourceSlug = searchParams.get("source")?.trim() || null; - const createNewConnection = searchParams.get("new") === "1"; + const createNewConnection = forceNewConnection || searchParams.get("new") === "1"; const routeStage = searchParams.get("stage")?.trim() || null; const resumeConnectionId = searchParams.get("resume")?.trim() || null; const oauthCallbackOutcome = searchParams.get("oauth"); @@ -630,6 +632,7 @@ export function ConnectionSetupFlow({ const hydratedResumeConnectionIdRef = useRef(null); const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState(null); const oauthPopupRef = useRef(null); + const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState(null); const oauthHandoffAbortRef = useRef(null); const [showConnectionChoice, setShowConnectionChoice] = useState( existingConnections.length > 0 && Boolean(onUseExisting), @@ -715,6 +718,51 @@ export function ConnectionSetupFlow({ return () => window.removeEventListener("message", receiveOAuthOutcome); }, [connectionIntentId, host, onComplete, onOAuthDeclined]); + // Standalone dialog hosts have no task interaction to receive a callback. + // Wait for this popup to return to our origin, then verify durable state via + // the API. Provider-window contents never determine the saved connection. + useEffect(() => { + if (host !== "dialog" || connectionIntentId || !dialogOAuthConnectionId) return; + let cancelled = false; + let checking = false; + const timer = window.setInterval(async () => { + if (checking || cancelled) return; + const popup = oauthPopupRef.current; + if (!popup || popup.closed) { + setDialogOAuthConnectionId(null); + setOAuthPhase("error"); + setOAuthError("The sign-in window closed. Try again to finish connecting GitHub."); + return; + } + let returned: URL; + try { returned = new URL(popup.location.href); } catch { return; } + if (returned.origin !== window.location.origin || !returned.pathname.includes(dialogOAuthConnectionId)) return; + if (returned.searchParams.has("oauth")) { + setDialogOAuthConnectionId(null); + setOAuthPhase("error"); + setOAuthError("Authorization did not complete. Finish setup in the sign-in window or try again."); + return; + } + if (returned.searchParams.get("success") !== "1") return; + checking = true; + try { + const connection = await toolsApi.getConnection(dialogOAuthConnectionId); + if (!cancelled && connection.status === "active") { + setDialogOAuthConnectionId(null); + popup.close(); + onComplete?.({ connectionId: connection.id }); + } + } catch { + if (!cancelled) { + setDialogOAuthConnectionId(null); + setOAuthPhase("error"); + setOAuthError("Could not confirm the connection. Try again."); + } + } finally { checking = false; } + }, 1000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [connectionIntentId, dialogOAuthConnectionId, host, onComplete]); + const resetGenericAuthState = () => { setLinkAuthMode("auto"); setLinkHeaders([newCustomHeaderRow()]); @@ -893,7 +941,7 @@ export function ConnectionSetupFlow({ refetchOnMount: "always", }); const existingOAuthConnection = useMemo( - () => reusableOAuthConnection( + () => forceNewConnection ? null : reusableOAuthConnection( directOAuthSource, applicationsQuery.data?.applications ?? [], connectionsQuery.data?.connections ?? [], @@ -901,7 +949,7 @@ export function ConnectionSetupFlow({ ? { applicationId: prefill.applicationId, draftOnly: true } : {}, ), - [applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId], + [applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId, forceNewConnection], ); const reconnectConnection = useMemo( () => reconnectConnectionId @@ -1015,8 +1063,9 @@ export function ConnectionSetupFlow({ const startOAuth = useCallback((connection: ToolConnection) => { onPhaseChange?.("authorizing"); reserveOAuthPopup(); + if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); mutateOAuthStart(connection); - }, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup]); + }, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]); /** * Commit the Access step's agent reach for a connection. Shared by the diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 5bdd23e832..a1a8d48467 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,3 +1,4 @@ +import { RepositoryEditor } from "@/components/RepositoryEditor"; import { useState } from "react"; import { ServicesList } from "./apps/app-detail/ServicesPanel"; import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip"; @@ -2115,6 +2116,18 @@ export function DesignGuide() { +
+ + {}} state="disconnected" onConnect={() => {}} onRetry={() => {}} /> + + + {}} onConnect={() => {}} onRetry={() => {}} /> + +

Loading, errors, empty search, mobile, and short viewports are covered in the Project repos Storybook stories.

+
+

Reusable env-var editor (agents, projects, environments, routines). One shared grid, an diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 8d9a64df7e..fb3bfd23d1 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -48,7 +48,7 @@ import { /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget"; +type ProjectBaseTab = "list" | "plugin-operations" | "workspaces" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -61,7 +61,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu const projectsIdx = segments.indexOf("projects"); if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null; const tab = segments[projectsIdx + 2]; - if (tab === "overview") return "overview"; + if (tab === "overview") return "configuration"; if (tab === "configuration") return "configuration"; if (tab === "budget") return "budget"; if (tab === "issues") return "list"; @@ -70,48 +70,6 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu return null; } -/* ── Overview tab content ── */ - -function OverviewContent({ - project, - onUpdate, - imageUploadHandler, -}: { - project: { description: string | null; status: string; targetDate: string | null }; - onUpdate: (data: Record) => void; - imageUploadHandler?: (file: File) => Promise; -}) { - return ( -

- onUpdate({ description })} - nullable - as="p" - className="text-sm text-muted-foreground" - placeholder="Add a description..." - multiline - imageUploadHandler={imageUploadHandler} - /> - -
-
- Status -
- -
-
- {project.targetDate && ( -
- Target Date -

{project.targetDate}

-
- )} -
-
- ); -} - /* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */ const DEFAULT_PROJECT_ICON = "folder"; @@ -536,15 +494,11 @@ export function ProjectDetail() { useEffect(() => { if (!project) return; - if (routeProjectRef === canonicalProjectRef) return; + if (routeProjectRef === canonicalProjectRef && !location.pathname.endsWith("/overview")) return; if (isProjectPluginTab(activeTab)) { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true }); return; } - if (activeTab === "overview") { - navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true }); - return; - } if (activeTab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true }); return; @@ -570,7 +524,7 @@ export function ProjectDetail() { return; } navigate(`/projects/${canonicalProjectRef}`, { replace: true }); - }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]); + }, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate, location.pathname]); useEffect(() => { closePanel(); @@ -695,7 +649,7 @@ export function ProjectDetail() { try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {} } if (cachedTab === "overview") { - return ; + return ; } if (cachedTab === "configuration") { return ; @@ -740,9 +694,7 @@ export function ProjectDetail() { navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`); return; } - if (tab === "overview") { - navigate(`/projects/${canonicalProjectRef}/overview`); - } else if (tab === "workspaces") { + if (tab === "workspaces") { navigate(`/projects/${canonicalProjectRef}/workspaces`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); @@ -879,7 +831,7 @@ export function ProjectDetail() { - {activeTab === "overview" && ( - updateProject.mutate(data)} - imageUploadHandler={async (file) => { - const asset = await uploadImage.mutateAsync(file); - return asset.contentPath; - }} - /> - )} + {activeTab === "list" && project?.id && resolvedCompanyId && ( diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index c1102a17ec..5d6a9060e2 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -13,6 +13,7 @@ import { AppsConnect } from "./AppsConnect"; const listGalleryMock = vi.hoisted(() => vi.fn()); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); +const getConnectionMock = vi.hoisted(() => vi.fn()); const connectAppMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); @@ -57,6 +58,7 @@ vi.mock("@/api/tools", () => ({ listGallery: (companyId: string) => listGalleryMock(companyId), listApplications: (companyId: string) => listApplicationsMock(companyId), listConnections: (companyId: string) => listConnectionsMock(companyId), + getConnection: (id: string) => getConnectionMock(id), connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input), startOAuth: (connectionId: string, input?: unknown) => startOAuthMock(connectionId, input), finishApp: (companyId: string, connectionId: string, input: unknown) => @@ -1432,6 +1434,35 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await act(async () => dialogRoot.unmount()); }); + it("returns a standalone GitHub popup to its host only after verifying the saved connection", async () => { + listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] }); + connectAppMock.mockResolvedValue({ + connectionId: "conn-github", application: { id: "app-github", name: "GitHub" }, + connection: { id: "conn-github", credentialPolicy: "per_user" }, + actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {}, + auth: { kind: "oauth" }, + }); + const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; + vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window); + const onComplete = vi.fn(); + getConnectionMock.mockResolvedValue({ id: "conn-github", status: "active" }); + const dialogRoot = await render(undefined, false, ); + await passAccessStep(); + await act(async () => buttonByText("Continue to GitHub")!.click()); + await flushReact(); + await flushReact(); + expect(connectAppMock, container.textContent ?? "").toHaveBeenCalled(); + expect(startOAuthMock, container.textContent ?? "").toHaveBeenCalledWith("conn-github", { asCurrentUser: true }); + expect(onComplete).not.toHaveBeenCalled(); + popup.location.href = `${window.location.origin}/CO/apps/conn-github/permissions?success=1`; + await act(async () => { + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "conn-github" }), { timeout: 2500 }); + }); + expect(getConnectionMock).toHaveBeenCalledWith("conn-github"); + expect(popup.close).toHaveBeenCalled(); + await act(async () => dialogRoot.unmount()); + }); + it("keeps the task dialog recoverable when the browser blocks its reserved OAuth popup", async () => { listGalleryMock.mockResolvedValue({ apps: [NOTION] }); connectAppMock.mockResolvedValue({ diff --git a/ui/storybook/prototypes/project-repos/ProjectConfigurationPage.tsx b/ui/storybook/prototypes/project-repos/ProjectConfigurationPage.tsx new file mode 100644 index 0000000000..dd80ed4e82 --- /dev/null +++ b/ui/storybook/prototypes/project-repos/ProjectConfigurationPage.tsx @@ -0,0 +1,86 @@ +import { useMemo, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { Boxes, ChevronRight, ChevronsUpDown, CircleCheck, Folder, History, Inbox, LayoutDashboard, Menu, Package, Repeat, Search, SquarePen, Star, Unplug, Users } from "lucide-react"; +import { ProjectProperties } from "@/components/ProjectProperties"; +import { PageTabBar } from "@/components/PageTabBar"; +import { Button } from "@/components/ui/button"; +import { Tabs } from "@/components/ui/tabs"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { storybookProjects } from "../../fixtures/paperclipData"; +import { RepositoryConfigurationSection, type PrototypeProps } from "./ProjectReposPrototype"; + +const navigation = [ + { label: "Search", icon: Search, path: "search" }, + { label: "Dashboard", icon: LayoutDashboard, path: "dashboard" }, + { label: "Inbox", icon: Inbox, path: "inbox" }, + { heading: "Work", label: "Tasks", icon: CircleCheck, path: "issues" }, + { label: "Projects", icon: Folder, path: "projects" }, + { label: "Routines", icon: Repeat, path: "routines" }, + { label: "Artifacts", icon: Package, path: "artifacts" }, + { heading: "Org", label: "Agents", icon: Users, path: "agents" }, + { label: "Skills", icon: Boxes, path: "skills" }, + { label: "Connectors", icon: Unplug, path: "apps" }, + { label: "Audit", icon: History, path: "activity" }, +]; + +function ReferenceSidebar() { + return ( + + ); +} + +export function ProjectConfigurationPrototype(props: PrototypeProps) { + const client = useMemo(() => { + const c = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } } }); + c.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: true, enableIsolatedWorkspaces: false, enableEnvironments: false }); + c.setQueryData(queryKeys.goals.list("company-storybook"), []); + c.setQueryData(queryKeys.secrets.list("company-storybook"), []); + c.setQueryData(queryKeys.secrets.userDefinitions("company-storybook"), []); + return c; + }, []); + const [project, setProject] = useState(() => ({ + ...storybookProjects[0]!, id: "project-onboarding-preview", name: "Onboarding", urlKey: "onboarding", + description: null, status: "in_progress", leadAgentId: null, goalId: null, goalIds: [], goals: [], env: null, + archivedAt: null, targetDate: null, workspaces: [], primaryWorkspace: null, + createdAt: new Date("2026-09-07T12:00:00Z"), updatedAt: new Date("2026-09-07T12:00:00Z"), + })); + const [activeTab, setActiveTab] = useState("configuration"); + const [starred, setStarred] = useState(false); + const [mobileNav, setMobileNav] = useState(false); + return ( + +
+
+ {mobileNav &&
} +
+
+ + Projects{project.name} +
+
+
+

{project.name}

+ +
+ setProject((previous) => ({ ...previous, ...data }))} onArchive={(archived) => setProject((previous) => ({ ...previous, archivedAt: archived ? new Date() : null }))} repositories={} /> +
+ {activeTab !== "configuration" &&

This story previews the Configuration tab.

} +
+
+
+
+
+ ); +} diff --git a/ui/storybook/prototypes/project-repos/ProjectReposPrototype.tsx b/ui/storybook/prototypes/project-repos/ProjectReposPrototype.tsx new file mode 100644 index 0000000000..454d4ee6b9 --- /dev/null +++ b/ui/storybook/prototypes/project-repos/ProjectReposPrototype.tsx @@ -0,0 +1,155 @@ +import { RepositoryEditor as ProductionRepositoryEditor } from "@/components/RepositoryEditor"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared"; +import { Check, GitBranch, Link, LockKeyhole, Folder, Plus, X } from "lucide-react"; +import { GithubIcon } from "@/components/icons/github-icon"; +import { SearchableSelect } from "@/components/SearchableSelect"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { queryKeys } from "@/lib/queryKeys"; +import { availableRepositories, type Repository, type RepositoryState } from "./fixtures"; + +// Everything in this directory is a local, interactive design proposal. +// Production screens, persistence, authorization, and API contracts are unchanged. + +function GitHubConnectionPreview({ onComplete, onCancel }: { onComplete: () => void; onCancel: () => void }) { + const client = useMemo(() => { + const c = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } } }); + c.setQueryData(queryKeys.apps.gallery("company-storybook"), { + // Mirror an enrolled instance's gallery response; raw catalog defaults + // hide platform_shared OAuth and would otherwise show the PAT fallback. + apps: CONNECTABLE_APP_DEFINITIONS.filter((app) => app.slug === "github").map((app) => ({ + ...app, + ownershipAvailability: { platform_shared: true, platform_provisioned: false, customer: true, dcr: true }, + })), + capabilities: { canSetCompanyInstall: true, canCreateOrganizationGrant: true, canConnectAsCurrentUser: true }, + }); + c.setQueryData(queryKeys.tools.applications("company-storybook"), { applications: [] }); + c.setQueryData(queryKeys.tools.connections("company-storybook"), { connections: [] }); + return c; + }, []); + return ( + +
{ + // Use the exact existing connector UI. Only the provider handoff is + // simulated: no popup, OAuth request, or connection mutation in stories. + const button = (event.target as HTMLElement).closest("button"); + if (button?.textContent?.trim() === "Continue to GitHub") { + event.preventDefault(); + event.stopPropagation(); + onComplete(); + } + }}> + +
+
+ ); +} + +function RepositoryEditor({ selected, onChange, initialState = "ready", personalOnly = false, onConnect }: { + selected: Repository[]; onChange: (repos: Repository[]) => void; initialState?: RepositoryState; personalOnly?: boolean; onConnect: () => void; +}) { + const [state, setState] = useState(initialState); + return onChange(repos.map((repo) => ({ ...repo, private: repo.private ?? false })))} state={state} + available={state === "ready" ? availableRepositories(personalOnly) : []} + onConnect={onConnect} onRetry={() => setState("ready")} />; +} + +export interface PrototypeProps { + initialName?: string; + initialState?: RepositoryState; + initialRepoIds?: string[]; + personalOnly?: boolean; + legacyUrl?: string; + startConnecting?: boolean; +} + +function useRepoDraft(props: PrototypeProps) { + const [repos, setRepos] = useState(() => availableRepositories().filter((repo) => props.initialRepoIds?.includes(repo.id))); + const [connecting, setConnecting] = useState(props.startConnecting ?? false); + const [connected, setConnected] = useState(false); + const [revision, setRevision] = useState(0); + return { repos, setRepos, connecting, setConnecting, revision, + state: connected ? "ready" as const : props.initialState ?? "ready", + finishConnection: () => { setConnected(true); setConnecting(false); setRevision((n) => n + 1); }, + }; +} + +export function NewProjectPrototype(props: PrototypeProps) { + const draft = useRepoDraft(props); + const [name, setName] = useState(props.initialName ?? ""); + const [open, setOpen] = useState(true); + const [created, setCreated] = useState(false); + const input = useRef(null); + return ( +
+ + + { event.preventDefault(); input.current?.focus(); input.current?.select(); }}> + {(draft.connecting || created) && {draft.connecting ? "Connect GitHub" : "Create project"}} + {draft.connecting ?
draft.setConnecting(false)} />
: created ? ( +
+

{name}

+

Project preview created with {draft.repos.length} {draft.repos.length === 1 ? "repo" : "repos"}.

+ +
+ ) : ( +
{ event.preventDefault(); if (name.trim()) setCreated(true); }}> +
+
+ Create project + +
+
+
+
+
+ draft.setConnecting(true)} /> +
+
+ + +
+
+ )} +
+
+
+ ); +} + +export function RepositoryConfigurationSection(props: PrototypeProps) { + const draft = useRepoDraft(props); + const [legacyUrl, setLegacyUrl] = useState(props.legacyUrl ?? ""); + const [editingLegacy, setEditingLegacy] = useState(false); + const [saved, setSaved] = useState({ repos: draft.repos, legacyUrl }); + const [notice, setNotice] = useState(""); + const dirty = JSON.stringify(draft.repos) !== JSON.stringify(saved.repos) || legacyUrl !== saved.legacyUrl; + return ( +
+
+ { draft.setRepos(repos); setNotice(""); }} initialState={draft.state} personalOnly={props.personalOnly} onConnect={() => draft.setConnecting(true)} /> + {props.legacyUrl &&
+ Existing repo URL + {editingLegacy ?
{ setLegacyUrl(event.target.value); setNotice(""); }} />
:
{legacyUrl || "No URL set"}
} +

Your existing URL is preserved alongside selected GitHub repos.

+
} +
+ {notice && {notice}} + {dirty && } + +
+
+ Connect GitHub draft.setConnecting(false)} /> +
+ ); +} diff --git a/ui/storybook/prototypes/project-repos/README.md b/ui/storybook/prototypes/project-repos/README.md new file mode 100644 index 0000000000..bc6d43fbbc --- /dev/null +++ b/ui/storybook/prototypes/project-repos/README.md @@ -0,0 +1,56 @@ +# Project repository proposal + +Review under **Proposals → Project repos** in Storybook. The repository editor is shared with production. Stories use local +fixtures for review; production screens use authenticated API requests. + +- New project follows the supplied layout: “Create project” heading, close on + the right, no expand control, an outlined folder beside the left-aligned, + initially focused “Project name” placeholder, and “Source repos · optional.” + There is no breadcrumb, description, status, goal, due date, or text URL field. +- The modal bounds its height to the dynamic viewport. Its title/name and + Cancel/Create actions stay visible while the source repo region scrolls. + The searchable dropdown has a separate scroll area bounded by Radix's + available viewport height, including when it opens above its trigger. +- Repo selection uses the existing SearchableSelect, searching accessible + personal/company connections, provider-ID deduplication, and repeatable + add/remove. Fixtures contain 63 unique accessible repos, one duplicate repo, + and an inaccessible personal connection. Selected repos leave the picker. +- GitHub setup uses the actual ConnectionSetupFlow. The story intercepts + “Continue to GitHub” and simulates a successful return without starting OAuth. + The project name and selected repos survive connect/cancel. +- Configuration now previews the whole Onboarding configuration tab, based on + https://bull.staging.paperclip.app/BUL/projects/onboarding/configuration. + It includes navigation, breadcrumbs, project title/star, tabs, the actual + ProjectProperties general fields/environment editor/danger zone, and the + proposed repo editor above environment variables. Status and Goals are + omitted, Created is the last row after the danger zone, and the Overview + tab is removed. Updated remains below environment variables. The sidebar is a reference + shell; its links open staging in a separate tab. Non-configuration tabs are + explicitly outside this proposal. Field edits and saves stay in memory. +- The full configuration story composes ProjectProperties directly through its + repositories slot. No DOM manipulation or duplicated configuration page is used. +- Existing text URLs remain editable beside selected GitHub repos in the + configuration stories. There is no new manual URL entry point in creation. + +## Review coverage + +Both groups include forty-selected-repo, mobile, and short-viewport stories. +Creation also includes mobile/short searchable picker stories. The short mobile +viewport is 390 × 420, useful for reduced screen space such as a visible keyboard; +this does not emulate a native keyboard. Loading, failure/retry, no accessible +repos, no matches, personal-only, multiple-connection, and legacy states remain. + +## Production contract + +Projects persist selected GitHub identities in project workspaces using +`metadata.githubRepositoryId`. Each selection has its own workspace; the first +is the default execution workspace. Legacy `repoUrl` and local workspaces remain +supported. Runtime credentials still come from the existing run identity resolver; +a selection does not grant agents additional GitHub access. + +The company repository endpoint includes only the current user's personal grants +and organization grants whose audience includes that user. It refreshes GitHub +installation metadata, supports PAT pagination, deduplicates provider IDs, and +reports partial failures. Creation validates all selections before saving the +project and repositories in one transaction. Configuration retains inaccessible +existing selections until explicitly removed and leaves legacy workspaces intact. diff --git a/ui/storybook/prototypes/project-repos/fixtures.ts b/ui/storybook/prototypes/project-repos/fixtures.ts new file mode 100644 index 0000000000..da8f4682e3 --- /dev/null +++ b/ui/storybook/prototypes/project-repos/fixtures.ts @@ -0,0 +1,55 @@ +/** Review fixtures only. These are not an API contract or an authorization implementation. */ +export interface Repository { + id: string; + fullName: string; + url: string; + private: boolean; + connections: string[]; +} + +interface Connection { + id: string; + label: string; + usable: boolean; + repos: Omit[]; +} + +const repo = (id: string, fullName: string, isPrivate = true) => ({ + id, fullName, url: `https://github.com/${fullName}`, private: isPrivate, +}); + +export const connections: Connection[] = [ + { + id: "personal", label: "Your GitHub · dotta", usable: true, + repos: [repo("101", "dotta/papercool"), repo("102", "dotta/experiments"), repo("201", "papercool/web")], + }, + { + id: "company", label: "Company GitHub · connected by Sam", usable: true, + repos: [repo("201", "papercool/web"), repo("202", "papercool/api"), repo("203", "papercool/docs", false), + ...["design-system", "mobile", "infrastructure", "integrations", "cli", "analytics", "templates", "status", "website", "sdk"].map((name, i) => repo(String(300 + i), `papercool/${name}`)), + ...Array.from({ length: 48 }, (_, i) => repo(String(400 + i), `papercool/service-${String(i + 1).padStart(2, "0")}`))], + }, + { id: "other-personal", label: "Sam’s private GitHub", usable: false, repos: [repo("900", "sam/private-project")] }, +]; + +// Provider IDs keep one repository stable across connections and name changes. +export function availableRepositories(personalOnly = false): Repository[] { + const byId = new Map(); + for (const connection of connections.filter((c) => c.usable && (!personalOnly || c.id === "personal"))) { + for (const item of connection.repos) { + const existing = byId.get(item.id); + if (existing) existing.connections.push(connection.label); + else byId.set(item.id, { ...item, connections: [connection.label] }); + } + } + return [...byId.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)); +} + +export type RepositoryState = "ready" | "disconnected" | "loading" | "error" | "empty"; + +export const crowdedRepoIds = availableRepositories().slice(0, 40).map((repo) => repo.id); + +export const reviewViewports = { + short: { name: "Short desktop", styles: { width: "1024px", height: "480px" } }, + mobileShort: { name: "Short mobile / keyboard-sized", styles: { width: "390px", height: "420px" } }, +}; diff --git a/ui/storybook/stories/new-project-repos.stories.tsx b/ui/storybook/stories/new-project-repos.stories.tsx new file mode 100644 index 0000000000..3d3bc78466 --- /dev/null +++ b/ui/storybook/stories/new-project-repos.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { NewProjectPrototype } from "../prototypes/project-repos/ProjectReposPrototype"; + +import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; + +const meta = { + title: "Proposals/Project repos/New project", + component: NewProjectPrototype, + tags: ["!autodocs"], + parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Interactive design proposal only. The project name starts empty and focused, with an outlined folder icon. The source repo region scrolls while the title, name, and actions remain visible. GitHub setup uses the existing ConnectionSetupFlow; Continue to GitHub simulates a successful provider return. Creation and repository changes stay in memory." } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { name: "01 · Name and optional repo" }; +export const NoGitHubConnection: Story = { name: "02 · GitHub not connected", args: { initialState: "disconnected" } }; +export const ExistingGitHubSetup: Story = { name: "03 · Existing GitHub connection UI", args: { initialState: "disconnected", startConnecting: true } }; +export const AllAvailableRepos: Story = { + name: "04 · Search personal + company repos (deduplicated)", + play: async ({ canvasElement }) => { + const screen = within(canvasElement.ownerDocument.body); + await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" })); + await expect(screen.getAllByRole("option").filter((option) => within(option).queryByText("papercool/web", { exact: true }))).toHaveLength(1); + await expect(screen.queryByText("sam/private-project")).not.toBeInTheDocument(); + }, +}; +export const PersonalConnectionOnly: Story = { name: "05 · Personal connection only", args: { personalOnly: true } }; +export const OneRepo: Story = { name: "06 · One selected repo", args: { initialRepoIds: ["201"] } }; +export const MultipleRepos: Story = { name: "07 · Multiple selected repos", args: { initialRepoIds: ["201", "202", "203"] } }; +export const LoadingRepos: Story = { name: "08 · Loading repos", args: { initialState: "loading" }, play: async ({ canvasElement }) => { + const screen = within(canvasElement.ownerDocument.body); + await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" })); +} }; +export const NoAccessibleRepos: Story = { name: "09 · Connected, no accessible repos", args: { initialState: "empty" }, play: LoadingRepos.play }; +export const RepoLoadFailed: Story = { name: "10 · Load failed, retry available", args: { initialState: "error" }, play: LoadingRepos.play }; +export const SearchNoResults: Story = { name: "11 · Search with no matches", play: async ({ canvasElement }) => { + const screen = within(canvasElement.ownerDocument.body); + await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" })); + await userEvent.type(screen.getByPlaceholderText("Search GitHub repos…"), "no-such-repository"); +} }; + +export const ManySelectedRepos: Story = { name: "12 · Forty selected repos", args: { initialName: "Onboarding", initialRepoIds: crowdedRepoIds } }; +export const ShortViewport: Story = { ...ManySelectedRepos, name: "13 · Forty repos · short desktop", globals: { viewport: { value: "short", isRotated: false } } }; +export const Mobile: Story = { name: "14 · Mobile · empty project", globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileManyRepos: Story = { ...ManySelectedRepos, name: "15 · Mobile · forty repos", globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileShortViewport: Story = { ...ManySelectedRepos, name: "16 · Mobile · short viewport", globals: { viewport: { value: "mobileShort", isRotated: false } } }; +export const ShortRepoPicker: Story = { ...AllAvailableRepos, name: "17 · Scroll repo picker · short desktop", globals: { viewport: { value: "short", isRotated: false } } }; +export const MobileRepoPicker: Story = { ...AllAvailableRepos, name: "18 · Scroll repo picker · short mobile", globals: { viewport: { value: "mobileShort", isRotated: false } } }; diff --git a/ui/storybook/stories/project-repos-configuration.stories.tsx b/ui/storybook/stories/project-repos-configuration.stories.tsx new file mode 100644 index 0000000000..6015a5475a --- /dev/null +++ b/ui/storybook/stories/project-repos-configuration.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProjectConfigurationPrototype } from "../prototypes/project-repos/ProjectConfigurationPage"; + +import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures"; + +const meta = { + title: "Proposals/Project repos/Configuration", + component: ProjectConfigurationPrototype, + tags: ["!autodocs"], + parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Full Onboarding configuration page, based on the Bull staging reference: sidebar, header, tabs, actual ProjectProperties, and the proposed source repo editor in the Codebase slot. Add/remove repos, save or discard, connect another GitHub account, and preserve existing text URLs. All writes and provider sign-in are simulated in Storybook." } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const MultipleRepos: Story = { name: "01 · Multiple repos", args: { initialRepoIds: ["201", "202", "203"] } }; +export const OneRepo: Story = { name: "02 · One repo", args: { initialRepoIds: ["201"] } }; +export const NoRepos: Story = { name: "03 · No repos yet" }; +export const NotConnected: Story = { name: "04 · Connect GitHub from configuration", args: { initialState: "disconnected" } }; +export const ConnectAnotherAccount: Story = { name: "05 · Existing connector UI, preserve repo draft", args: { initialRepoIds: ["201"], startConnecting: true } }; +export const LegacyUrlPreserved: Story = { name: "06 · Existing text URL + selected GitHub repos", args: { initialRepoIds: ["201"], legacyUrl: "https://github.com/papercool/legacy-service" } }; +export const RepoLoadFailed: Story = { name: "07 · Existing repos survive load failure", args: { initialRepoIds: ["201", "202"], initialState: "error" } }; + +export const ManyRepos: Story = { name: "08 · Full page · forty repos", args: { initialRepoIds: crowdedRepoIds } }; +export const ShortPage: Story = { ...ManyRepos, name: "09 · Full page · short desktop", globals: { viewport: { value: "short", isRotated: false } } }; +export const MobilePage: Story = { ...MultipleRepos, name: "10 · Full configuration · mobile", globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileManyRepos: Story = { ...ManyRepos, name: "11 · Full page · forty repos on mobile", globals: { viewport: { value: "mobile", isRotated: false } } }; +export const MobileShortPage: Story = { ...ManyRepos, name: "12 · Full page · short mobile", globals: { viewport: { value: "mobileShort", isRotated: false } } };