Vectrum
Sign In

Merge branch 't3/migrate-repo-to-organization'

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 2 parents 647975d 836f571commit2704b3b

Showing 5 changed files with 93 additions and 4 deletions.

Merge commit — showing changes against the first parent.

+44−2apps/web/app/[owner]/[repo]/settings/forms.tsxView File
@@ -1,11 +1,12 @@
11 "use client";
22
33 import { useActionState } from "react";
4- import { Checkbox } from "@unlocalhosted/metalui";
4+ import Link from "next/link";
5+ import { Checkbox, Select } from "@unlocalhosted/metalui";
56 import type { FormState } from "@/app/actions/types";
67 import { DeleteZone } from "@/components/delete-zone";
78 import { ImageUploadForm } from "@/components/image-upload-form";
8- import { BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons";
9+ import { ArrowRightIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon } from "@/components/icons";
910 import { ChoiceCards, TextField } from "@/components/settings-fields";
1011 import { PlateBody, PlateFooter } from "@/components/settings-plate";
1112 import { Alert, Button, RepoAvatar, type RepoImageRef } from "@/components/ui";
@@ -51,6 +52,47 @@ export function RenameForm({ action, name }: { action: Action; name: string }) {
5152 );
5253 }
5354
55+ /** Moving the repository to another account: one of `owners`, the accounts the viewer may create repositories in. */
56+ export function TransferForm({ action, owners, name }: { action: Action; owners: string[]; name: string }) {
57+ const [state, formAction, pending] = useActionState(action, undefined);
58+ if (owners.length === 0) {
59+ return (
60+ <PlateBody>
61+ <p className="type-body text-ink2">
62+ Move this repository to an organization you’re an admin or owner of. You don’t have one yet:{" "}
63+ <Link href="/organizations/new" className="text-ink underline">
64+ create an organization
65+ </Link>{" "}
66+ first.
67+ </p>
68+ </PlateBody>
69+ );
70+ }
71+ return (
72+ <form action={formAction}>
73+ <PlateBody>
74+ <p className="type-body text-ink2">
75+ Its issues, pull requests, wiki, releases, packages and collaborators move with it, and the new owner’s access rules apply. Clone
76+ URLs and links change to the new owner’s name.
77+ </p>
78+ <div className="flex max-w-xl flex-wrap items-end gap-2">
79+ <div className="flex flex-col gap-1.5">
80+ <span className="type-ui text-ink">New owner</span>
81+ <Select name="owner" aria-label="New owner" defaultValue={owners[0]} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" />
82+ </div>
83+ <span aria-hidden className="flex h-8 items-center type-content text-ink3">
84+ / {name}
85+ </span>
86+ <Button icon={<ArrowRightIcon />} disabled={pending}>
87+ {pending ? "Transferring…" : "Transfer"}
88+ </Button>
89+ </div>
90+ {state?.error && <Alert>{state.error}</Alert>}
91+ </PlateBody>
92+ </form>
93+ );
94+ }
95+
5496 export function GeneralForm({
5597 action,
5698 description,
+8−1apps/web/app/[owner]/[repo]/settings/page.tsxView File
@@ -3,20 +3,24 @@ import {
33 deleteRepoAction,
44 removeRepoImageAction,
55 renameRepoAction,
6+ transferRepoAction,
67 updateFeaturesAction,
78 updateRepoAction,
89 updateRepoImageAction,
910 } from "@/app/actions/repo";
1011 import { PlateSection } from "@/components/settings-plate";
1112 import { PageTitle } from "@/components/ui";
13+ import { repoOwnersFor } from "@/lib/orgs";
1214 import { getRepo } from "@/lib/repo";
13- import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms";
15+ import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm, TransferForm } from "./forms";
1416
1517 export const metadata: Metadata = { title: "Settings" };
1618
1719 export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) {
1820 const { owner, repo: repoParam } = await params;
1921 const repo = await getRepo(owner, repoParam);
22+ // Where it can move: accounts the viewer may create repositories in, other than its own.
23+ const owners = repo.viewer ? (await repoOwnersFor(repo.viewer)).map((o) => o.username).filter((o) => o !== repo.owner) : [];
2024 return (
2125 <div>
2226 <PageTitle title="General" />
@@ -41,6 +45,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[
4145 <PlateSection title="Features">
4246 <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} />
4347 </PlateSection>
48+ <PlateSection title="Transfer">
49+ <TransferForm action={transferRepoAction.bind(null, repo.owner, repo.name)} owners={owners} name={repo.name} />
50+ </PlateSection>
4451 <PlateSection title="Danger zone">
4552 <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} />
4653 </PlateSection>
+17−0apps/web/app/actions/repo.tsView File
@@ -12,6 +12,7 @@ import {
1212 RepoError,
1313 setRepoImage,
1414 setWikiEnabled,
15+ transferRepo,
1516 updateRepo,
1617 type Visibility,
1718 } from "@/lib/repos";
@@ -103,6 +104,22 @@ export async function renameRepoAction(owner: string, name: string, _prev: FormS
103104 redirect(path);
104105 }
105106
107+ /** Moves the repository to an account the viewer may create repositories in: theirs, or an organization they're an admin of. */
108+ export async function transferRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
109+ let path = "";
110+ const result = await attempt(async () => {
111+ const { repo, viewer } = await repoForAction(owner, name, "admin");
112+ const ownerName = String(formData.get("owner") ?? "");
113+ const newOwner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName);
114+ if (!newOwner || !(await mayCreateRepos(newOwner, viewer))) throw new ActionError(`You can't move repositories to ${ownerName}.`);
115+ const moved = await transferRepo(repo, newOwner);
116+ revalidatePath("/", "layout");
117+ path = `/${moved.owner}/${moved.name}/settings`;
118+ });
119+ if (result?.error) return result;
120+ redirect(path);
121+ }
122+
106123 export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
107124 const result = await attempt(async () => {
108125 const { repo } = await repoForAction(owner, name, "admin");
+23−0apps/web/lib/repos.tsView File
@@ -350,6 +350,29 @@ export async function renameRepo(repo: RepoRecord, newName: string) {
350350 return (await findRepoById(repo.id))!;
351351 }
352352
353+ /**
354+ * Moves a repository to another account (an organization, or a person's) under the same name. Its
355+ * issues, pull requests, wiki, releases, images, forks and collaborators come along: all of them
356+ * are kept by the repository's id. The new owner stops being a collaborator: they own it now.
357+ */
358+ export async function transferRepo(repo: RepoRecord, newOwner: User) {
359+ if (newOwner.id === repo.ownerId) throw new RepoError(`${repo.owner}/${repo.name} already belongs to ${newOwner.username}.`);
360+ if (await findRepo(newOwner.username, repo.name)) throw new RepoError(alreadyHas(newOwner, repo.name));
361+ const newDir = repoPath(newOwner.username, repo.name);
362+ if (await pathExists(newDir)) throw new RepoError(`A repository directory for ${newOwner.username}/${repo.name} already exists on disk.`);
363+ await mkdir(path.dirname(newDir), { recursive: true });
364+ await rename(repo.dir, newDir);
365+ try {
366+ await (await repos()).update(repo.id, { owner: newOwner.id });
367+ } catch (e) {
368+ await rename(newDir, repo.dir);
369+ if (isUniqueViolation(e)) throw new RepoError(alreadyHas(newOwner, repo.name));
370+ throw e;
371+ }
372+ await removeCollaborator(repo.id, newOwner.id);
373+ return (await findRepoById(repo.id))!;
374+ }
375+
353376 /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
354377 export async function deleteRepo(repo: RepoRecord) {
355378 // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module.
+1−1docs/features.mdView File
@@ -2,7 +2,7 @@
22
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
44 - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
5- - **Repositories**: public or private, rename, delete, optional README on creation
5+ - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
77 - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Owners can upload an avatar for the organization (PNG, JPEG, GIF or WebP, up to 1 MB) in its *General* settings; its repositories without an image of their own show it too. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
88 - **Likes and lists**: anyone signed in can *Like* a repository they can see; the repository shows how many people like it (and who, under *likes*), and a person's profile lists what they like under *Liked*. The *Save* key puts a repository in lists, or makes a new one on the spot. Lists belong to a person or an organization, live at `/lists/<owner>/<list>` and on the profile's *Lists* tab, and are public (anyone can see them) or private (only that person, or the organization's members). Members with *Write* or more manage an organization's lists. A list never shows a repository to someone who couldn't see it anyway, so private repositories can go in public lists