mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Projects give tasks a common source repository and execution context. > - The current project form asks for a raw URL and unrelated metadata. > - Teams need to select several repos from GitHub connections they can use. > - This pull request implements the reviewed project form and repository editor. > - The server checks credential ownership and shared audiences before discovery. > - Existing workspace URLs and runtime identity rules remain compatible. ## Linked Issues or Issue Description **Problem or motivation** Project creation accepts one raw repository URL. It does not help users select repos from their usable GitHub connections or attach several repos together. **Proposed solution** Add a shared GitHub repository picker to project creation and Configuration. Support multiple selections, transactional persistence, and the existing GitHub setup flow. Simplify the project form and Configuration tab as reviewed. **Alternatives considered** Keep a raw URL field or add a separate repository table. The existing workspace collection already supports several repositories and keeps legacy URLs compatible. **Roadmap alignment** This builds on the shipped MCP Tool Gateway and Apps capability. It does not change runtime credential delegation. Related work: #11662 addresses the existing dialog's viewport limits. #4552 addresses generic Git URLs; this change preserves those URLs in existing workspaces. ## What Changed - Add company-scoped repository discovery from usable personal and shared GitHub grants, with provider-ID deduplication, PAT pagination, and partial failure handling. - Document the repository endpoints and board access requirements in OpenAPI. - Validate new selections and save projects with multiple repository workspaces in one transaction. Preserve legacy URLs and existing selections whose access was lost. - Implement the reviewed Create project dialog, shared repository editor, scrolling, and mobile layout. - Move repositories above environment variables, remove Status and Goals controls and env help paragraphs, move Created to the bottom, and redirect Overview to Configuration. - Reuse GitHub setup in dialogs, preserve project drafts, and verify popup completion through the API. - Replace the configuration story's DOM adapter with explicit production composition. Keep the reviewed mobile and short-viewport stories. ## Verification - Passed: `pnpm build`, `pnpm -r typecheck`, `pnpm build-storybook`, and `pnpm check:token-gates`. - Passed: focused repository access, database persistence, configuration, and connection setup tests. - Passed: `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/project-repositories.spec.ts`. - The browser tests use a real temporary server/database. They cover create, forty persisted repos, mobile scrolling, save/reload, legacy URL editing, and rejection without a partial project. - GitHub responses and popup completion use deterministic fixtures. No real GitHub account was authorized by the test suite. - All CI general, serialized server, and browser test shards pass on the final commit. - The local full-suite run overlapped review edits and was stopped; fresh repository, OpenAPI, UI/CLI, and connection tests pass. Unrelated local worker, built-in-agent, and routine timing/socket failures passed isolated reruns. - Final commit `1b3308dca`: all CI gates pass, including build, runner verification, typecheck, canary dry run, and security checks. Greptile is 5/5 with no unresolved review threads. - Storybook visual regression is opt-in and was skipped by CI; the Storybook build passed locally. ## Risks - Repository discovery depends on provider availability. Failed connections are reported while successful results stay usable. - Selections identify source workspaces; they do not grant agents new credentials. The existing primary-workspace and responsible-user identity rules still apply. - No database migration is needed. Existing API status, goals, dates, and manual workspace URLs remain supported. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code execution, and browser tools. The runtime does not expose a more specific model deployment ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
86 lines
5.8 KiB
TypeScript
86 lines
5.8 KiB
TypeScript
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 (
|
||
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
|
||
<GithubIcon className="size-4 shrink-0 text-muted-foreground" />
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||
<span className="truncate text-sm font-medium" title={repo.fullName}>{repo.fullName}</span>
|
||
{repo.connections.length > 0 && <span className="truncate text-xs text-muted-foreground" title={repo.connections.join(" · ")}>
|
||
{repo.connections.join(" · ")}
|
||
</span>}
|
||
</div>
|
||
{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" aria-label="Private repository" />}
|
||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove ${repo.fullName}`} onClick={onRemove}><X className="size-4" /></Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (showPicker) picker.current?.querySelector<HTMLButtonElement>('[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 (
|
||
<fieldset disabled={disabled} className="flex min-w-0 flex-col gap-3">
|
||
<div className="flex items-baseline gap-2"><span className="text-sm font-medium">Source repos</span><span className="text-xs text-muted-foreground">optional</span></div>
|
||
{selected.map((repo) => <RepoRow key={repo.id} repo={repo} onRemove={() => onChange(selected.filter((item) => item.id !== repo.id))} />)}
|
||
{state === "disconnected" ? (
|
||
<Popover>
|
||
<PopoverTrigger asChild><Button type="button" variant="outline" className={addClassName}><GithubIcon className="size-4" />{addLabel}</Button></PopoverTrigger>
|
||
<PopoverContent align="start" className="w-72">
|
||
<div className="flex flex-col gap-3">
|
||
<GithubIcon className="size-5" />
|
||
<div className="flex flex-col gap-1"><p className="text-sm font-medium">Connect GitHub to pick a repo</p><p className="text-xs text-muted-foreground">Choose from repos you can access through your GitHub connections.</p></div>
|
||
<Button type="button" onClick={onConnect}><GithubIcon className="size-4" />Connect GitHub</Button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
) : showPicker ? (
|
||
<div ref={picker} className="flex flex-col gap-2">
|
||
<SearchableSelect<string, (typeof options)[number]>
|
||
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={() => <span className="flex items-center gap-2 text-foreground"><GithubIcon className="size-4" />{addLabel}</span>}
|
||
renderOption={({ repo }) => <><GitBranch className="size-4 shrink-0 text-muted-foreground" /><span className="flex min-w-0 flex-1 flex-col gap-1"><span className="truncate">{repo.fullName}</span><span className="truncate text-xs text-muted-foreground">{repo.connections.join(" · ")}</span></span>{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" />}</>}
|
||
onValueChange={(_, option) => { onChange([...selected, option.repo]); setShowPicker(false); }}
|
||
createItem={state === "error"
|
||
? { render: () => <>Try again</>, onSelect: () => { onRetry(); } }
|
||
: { render: () => <><Plus className="size-4" />Connect another GitHub account</>, onSelect: onConnect }}
|
||
/>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs text-muted-foreground">{"All GitHub connections you can use."}</p>
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => setShowPicker(false)}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
) : <Button type="button" variant="outline" className={addClassName} onClick={() => setShowPicker(true)}><GithubIcon className="size-4" />{addLabel}</Button>}
|
||
</fieldset>
|
||
);
|
||
}
|
||
|